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.
- package/.agent/decisions/2026-05-20-live-event-fetch-permissions-id.md +23 -0
- package/CHANGELOG.md +19 -0
- package/package.json +1 -1
- package/src/contentTypeConfig.js +13 -15
- package/src/index.d.ts +6 -2
- package/src/index.js +6 -2
- package/src/infrastructure/sanity/README.md +230 -0
- package/src/infrastructure/sanity/SanityClient.ts +105 -0
- package/src/infrastructure/sanity/clients/ContentClient.ts +164 -0
- package/src/infrastructure/sanity/examples/usage.ts +101 -0
- package/src/infrastructure/sanity/executors/FetchQueryExecutor.ts +110 -0
- package/src/infrastructure/sanity/index.ts +19 -0
- package/src/infrastructure/sanity/interfaces/ConfigProvider.ts +6 -0
- package/src/infrastructure/sanity/interfaces/FetchByIdOptions.ts +7 -0
- package/src/infrastructure/sanity/interfaces/QueryExecutor.ts +8 -0
- package/src/infrastructure/sanity/interfaces/SanityConfig.ts +10 -0
- package/src/infrastructure/sanity/interfaces/SanityError.ts +7 -0
- package/src/infrastructure/sanity/interfaces/SanityQuery.ts +5 -0
- package/src/infrastructure/sanity/interfaces/SanityResponse.ts +6 -0
- package/src/infrastructure/sanity/providers/DefaultConfigProvider.ts +38 -0
- package/src/lib/sanity/decorators/base.ts +142 -0
- package/src/lib/sanity/decorators/examples.ts +229 -0
- package/src/lib/sanity/decorators/navigate-to.ts +139 -0
- package/src/lib/sanity/decorators/need-access.ts +40 -0
- package/src/lib/sanity/decorators/page-type.ts +35 -0
- package/src/services/awards/award-query.js +71 -0
- package/src/services/contentAggregator.js +1 -1
- package/src/services/multi-user-accounts/multi-user-accounts.ts +11 -7
- package/src/services/user/memberships.ts +46 -34
- package/src/services/user/profile.ts +66 -0
- package/test/unit/infrastructure/sanity/ContentClient.test.ts +168 -0
- package/test/unit/infrastructure/sanity/DefaultConfigProvider.test.ts +93 -0
- package/test/unit/infrastructure/sanity/FetchQueryExecutor.test.ts +174 -0
- package/test/unit/infrastructure/sanity/SanityClient.test.ts +140 -0
- package/test/unit/lib/sanity/decorators/base.test.ts +368 -0
- package/test/unit/lib/sanity/decorators/navigate-to.test.ts +266 -0
- package/test/unit/lib/sanity/decorators/need-access.test.ts +89 -0
- package/test/unit/lib/sanity/decorators/page-type.test.ts +81 -0
- package/.claude/settings.local.json +0 -23
- package/src/services/user/profile.js +0 -43
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import {
|
|
2
|
+
decorateAll,
|
|
3
|
+
decorateAllAsync,
|
|
4
|
+
decorateAsync,
|
|
5
|
+
type FieldDecorator,
|
|
6
|
+
type FieldDecoratorAsync,
|
|
7
|
+
} from './base'
|
|
8
|
+
import { accessDecorator, decorateAccess, type AccessDecoratable } from './need-access'
|
|
9
|
+
import { pageTypeDecorator, decoratePageType, type PageTypeDecoratable } from './page-type'
|
|
10
|
+
import {
|
|
11
|
+
decorateNavigateTo,
|
|
12
|
+
navigateToDecorator,
|
|
13
|
+
WithNavigateTo,
|
|
14
|
+
type NavigateToDecoratable,
|
|
15
|
+
} from './navigate-to'
|
|
16
|
+
import type { UserPermissions } from '../../../services/permissions'
|
|
17
|
+
|
|
18
|
+
interface ContentRow extends AccessDecoratable, PageTypeDecoratable {
|
|
19
|
+
id: number
|
|
20
|
+
type?: string
|
|
21
|
+
permission_id?: number[]
|
|
22
|
+
children?: ContentRow[]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const perms: UserPermissions = {
|
|
26
|
+
permissions: [78, 91],
|
|
27
|
+
isAdmin: false,
|
|
28
|
+
isModerator: false,
|
|
29
|
+
isABasicMember: true,
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const rows: ContentRow[] = [
|
|
33
|
+
{
|
|
34
|
+
id: 1,
|
|
35
|
+
type: 'course',
|
|
36
|
+
permission_id: [78],
|
|
37
|
+
children: [
|
|
38
|
+
{ id: 2, type: 'song', permission_id: [91] },
|
|
39
|
+
{ id: 3, type: 'play-along' },
|
|
40
|
+
],
|
|
41
|
+
},
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
export function singleDecoratorViaWrapper() {
|
|
45
|
+
const decorated = decorateAccess(rows, perms)
|
|
46
|
+
decorated[0].need_access satisfies boolean
|
|
47
|
+
return decorated
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function chainedWrappers() {
|
|
51
|
+
const withAccess = decorateAccess(rows, perms)
|
|
52
|
+
const withBoth = decoratePageType(withAccess)
|
|
53
|
+
withBoth[0].need_access satisfies boolean
|
|
54
|
+
withBoth[0].page_type satisfies 'song' | 'lesson'
|
|
55
|
+
return withBoth
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function composedSingleWalk() {
|
|
59
|
+
type Composed = AccessDecoratable & PageTypeDecoratable
|
|
60
|
+
const decorators: FieldDecorator<Composed>[] = [
|
|
61
|
+
accessDecorator(perms) as FieldDecorator<Composed>,
|
|
62
|
+
pageTypeDecorator as FieldDecorator<Composed>,
|
|
63
|
+
]
|
|
64
|
+
return decorateAll(rows, decorators)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function conditionalComposition(opts: { withAccess: boolean; withPageType: boolean }) {
|
|
68
|
+
type Composed = AccessDecoratable & PageTypeDecoratable
|
|
69
|
+
const decorators: FieldDecorator<Composed>[] = []
|
|
70
|
+
if (opts.withAccess) {
|
|
71
|
+
decorators.push(accessDecorator(perms) as FieldDecorator<Composed>)
|
|
72
|
+
}
|
|
73
|
+
if (opts.withPageType) {
|
|
74
|
+
decorators.push(pageTypeDecorator as FieldDecorator<Composed>)
|
|
75
|
+
}
|
|
76
|
+
return decorateAll(rows, decorators)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
interface ProgressDecoratable extends AccessDecoratable {
|
|
80
|
+
progress_percent?: number
|
|
81
|
+
is_liked?: boolean
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function fetchProgress(id: number): Promise<number> {
|
|
85
|
+
return id * 10
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function fetchLiked(id: number): Promise<boolean> {
|
|
89
|
+
return id % 2 === 0
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function singleAsyncDecorator() {
|
|
93
|
+
const decorated = (await decorateAsync(rows, 'progress_percent', (item) =>
|
|
94
|
+
fetchProgress(item.id as number)
|
|
95
|
+
)) as ProgressDecoratable[]
|
|
96
|
+
decorated[0].progress_percent satisfies number | undefined
|
|
97
|
+
return decorated
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export async function parallelAsyncDecorators() {
|
|
101
|
+
const decorators: FieldDecoratorAsync<ProgressDecoratable>[] = [
|
|
102
|
+
{
|
|
103
|
+
field: 'progress_percent',
|
|
104
|
+
compute: (item) => fetchProgress(item.id as number),
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
field: 'is_liked',
|
|
108
|
+
compute: (item) => fetchLiked(item.id as number),
|
|
109
|
+
},
|
|
110
|
+
]
|
|
111
|
+
const decorated = (await decorateAllAsync(
|
|
112
|
+
rows as ProgressDecoratable[],
|
|
113
|
+
decorators
|
|
114
|
+
)) as ProgressDecoratable[]
|
|
115
|
+
decorated[0].progress_percent satisfies number | undefined
|
|
116
|
+
decorated[0].is_liked satisfies boolean | undefined
|
|
117
|
+
return decorated
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export async function mixedSyncThenAsync() {
|
|
121
|
+
const withAccess = decorateAll(rows, [
|
|
122
|
+
accessDecorator(perms) as FieldDecorator<ProgressDecoratable>,
|
|
123
|
+
]) as ProgressDecoratable[]
|
|
124
|
+
return decorateAllAsync(withAccess, [
|
|
125
|
+
{
|
|
126
|
+
field: 'progress_percent',
|
|
127
|
+
compute: (item) => fetchProgress(item.id as number),
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
field: 'is_liked',
|
|
131
|
+
compute: (item) => fetchLiked(item.id as number),
|
|
132
|
+
},
|
|
133
|
+
])
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const courseLesson = (id: number): NavigateToDecoratable => ({
|
|
137
|
+
id,
|
|
138
|
+
type: 'course-lesson',
|
|
139
|
+
brand: 'drumeo',
|
|
140
|
+
thumbnail: '',
|
|
141
|
+
published_on: null,
|
|
142
|
+
status: 'published',
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
const navigateRows: NavigateToDecoratable[] = [
|
|
146
|
+
{
|
|
147
|
+
id: 1,
|
|
148
|
+
type: 'course',
|
|
149
|
+
brand: 'drumeo',
|
|
150
|
+
thumbnail: '',
|
|
151
|
+
published_on: null,
|
|
152
|
+
status: 'published',
|
|
153
|
+
children: [courseLesson(101), courseLesson(102), courseLesson(103)],
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
id: 2,
|
|
157
|
+
type: 'course-collection',
|
|
158
|
+
brand: 'drumeo',
|
|
159
|
+
thumbnail: '',
|
|
160
|
+
published_on: null,
|
|
161
|
+
status: 'published',
|
|
162
|
+
children: [
|
|
163
|
+
{
|
|
164
|
+
id: 201,
|
|
165
|
+
type: 'course',
|
|
166
|
+
brand: 'drumeo',
|
|
167
|
+
thumbnail: '',
|
|
168
|
+
published_on: null,
|
|
169
|
+
status: 'published',
|
|
170
|
+
children: [courseLesson(301), courseLesson(302)],
|
|
171
|
+
},
|
|
172
|
+
],
|
|
173
|
+
},
|
|
174
|
+
]
|
|
175
|
+
|
|
176
|
+
export async function singleAsyncNavigateTo() {
|
|
177
|
+
const decorated = await decorateNavigateTo(navigateRows)
|
|
178
|
+
void decorated[0].navigateTo
|
|
179
|
+
void decorated[1].navigateTo?.child
|
|
180
|
+
return decorated
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export async function navigateToOnSingleItem() {
|
|
184
|
+
const decorated = await decorateNavigateTo(navigateRows[0])
|
|
185
|
+
void decorated.navigateTo
|
|
186
|
+
return decorated
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export async function navigateToComposedWithAccess() {
|
|
190
|
+
interface ContentWithNav extends NavigateToDecoratable, AccessDecoratable {
|
|
191
|
+
permission_id?: number[]
|
|
192
|
+
children?: ContentWithNav[]
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const items: ContentWithNav[] = navigateRows.map((row) => ({
|
|
196
|
+
...row,
|
|
197
|
+
permission_id: [78],
|
|
198
|
+
}))
|
|
199
|
+
|
|
200
|
+
const withAccess = decorateAccess(items, perms)
|
|
201
|
+
const withBoth = await decorateNavigateTo(withAccess)
|
|
202
|
+
|
|
203
|
+
withBoth[0].need_access satisfies boolean
|
|
204
|
+
void withBoth[0].navigateTo
|
|
205
|
+
return withBoth
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export async function navigateToParallelWithProgress() {
|
|
209
|
+
interface ContentWithNavAndProgress extends NavigateToDecoratable {
|
|
210
|
+
progress_percent?: number
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const items = navigateRows as ContentWithNavAndProgress[]
|
|
214
|
+
const decorators: FieldDecoratorAsync<ContentWithNavAndProgress>[] = [
|
|
215
|
+
navigateToDecorator as FieldDecoratorAsync<ContentWithNavAndProgress>,
|
|
216
|
+
{
|
|
217
|
+
field: 'progress_percent',
|
|
218
|
+
compute: (item) => fetchProgress(item.id),
|
|
219
|
+
},
|
|
220
|
+
]
|
|
221
|
+
const decorated = (await decorateAllAsync(
|
|
222
|
+
items,
|
|
223
|
+
decorators
|
|
224
|
+
)) as WithNavigateTo<ContentWithNavAndProgress>[]
|
|
225
|
+
|
|
226
|
+
void decorated[0].navigateTo
|
|
227
|
+
decorated[0].progress_percent satisfies number | undefined
|
|
228
|
+
return decorated
|
|
229
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { decorateAllAsync, type Decoratable, type FieldDecoratorAsync } from './base'
|
|
2
|
+
import {
|
|
3
|
+
findIncompleteLesson,
|
|
4
|
+
getLastInteractedOf,
|
|
5
|
+
getProgressState,
|
|
6
|
+
getProgressStateByIds,
|
|
7
|
+
} from '../../../services/contentProgress.js'
|
|
8
|
+
import {
|
|
9
|
+
COLLECTION_TYPE,
|
|
10
|
+
CollectionParameter,
|
|
11
|
+
STATE,
|
|
12
|
+
} from '../../../services/sync/models/ContentProgress'
|
|
13
|
+
|
|
14
|
+
export const NAVIGATE_TO_FIELD = 'navigateTo' as const
|
|
15
|
+
|
|
16
|
+
const NAVIGABLE_TYPES = [
|
|
17
|
+
'course',
|
|
18
|
+
'guided-course',
|
|
19
|
+
'course-collection',
|
|
20
|
+
'song-tutorial',
|
|
21
|
+
'learning-path-v2',
|
|
22
|
+
'skill-pack',
|
|
23
|
+
] as const
|
|
24
|
+
|
|
25
|
+
const COURSE_FLOW_TYPES = ['course', 'skill-pack', 'song-tutorial']
|
|
26
|
+
const GUIDED_FLOW_TYPES = ['guided-course', COLLECTION_TYPE.LEARNING_PATH]
|
|
27
|
+
const TWO_DEPTH_TYPES = ['course-collection']
|
|
28
|
+
|
|
29
|
+
export interface NavigateToDecoratable extends Decoratable {
|
|
30
|
+
id: number
|
|
31
|
+
type: string
|
|
32
|
+
brand: string
|
|
33
|
+
thumbnail: string
|
|
34
|
+
published_on: string | null
|
|
35
|
+
status: string
|
|
36
|
+
children?: NavigateToDecoratable[]
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface NavigateTo {
|
|
40
|
+
id: number
|
|
41
|
+
type: string
|
|
42
|
+
brand: string
|
|
43
|
+
thumbnail: string
|
|
44
|
+
published_on: string | null
|
|
45
|
+
status: string
|
|
46
|
+
child: NavigateTo | null
|
|
47
|
+
collection: CollectionParameter | null
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type WithNavigateTo<T extends NavigateToDecoratable> = T & {
|
|
51
|
+
navigateTo: NavigateTo | null
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function buildNavigateTo(
|
|
55
|
+
content: NavigateToDecoratable,
|
|
56
|
+
child: NavigateTo | null = null,
|
|
57
|
+
collection: NavigateTo['collection'] = null
|
|
58
|
+
): NavigateTo {
|
|
59
|
+
return {
|
|
60
|
+
id: content.id,
|
|
61
|
+
type: content.type,
|
|
62
|
+
brand: content.brand,
|
|
63
|
+
thumbnail: content.thumbnail,
|
|
64
|
+
published_on: content.published_on,
|
|
65
|
+
status: content.status,
|
|
66
|
+
child,
|
|
67
|
+
collection,
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function computeNavigateTo(content: NavigateToDecoratable): Promise<NavigateTo | null> {
|
|
72
|
+
if (!NAVIGABLE_TYPES.includes(content.type as (typeof NAVIGABLE_TYPES)[number])) return null
|
|
73
|
+
|
|
74
|
+
const children = content.children
|
|
75
|
+
if (!children || children.length === 0) return null
|
|
76
|
+
|
|
77
|
+
const contentState = await getProgressState(content.id)
|
|
78
|
+
if (contentState !== STATE.STARTED) {
|
|
79
|
+
const firstChild = children[0]
|
|
80
|
+
const childNav = TWO_DEPTH_TYPES.includes(content.type)
|
|
81
|
+
? await computeNavigateTo(firstChild)
|
|
82
|
+
: null
|
|
83
|
+
return buildNavigateTo(firstChild, childNav)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const childrenIds = children.map((c) => c.id)
|
|
87
|
+
const childrenById = new Map(children.map((c) => [c.id, c]))
|
|
88
|
+
const childrenStates = (await getProgressStateByIds(childrenIds)) as Map<number, STATE>
|
|
89
|
+
const lastInteractedId = (await getLastInteractedOf(childrenIds)) as number
|
|
90
|
+
|
|
91
|
+
if (COURSE_FLOW_TYPES.includes(content.type)) {
|
|
92
|
+
const lastInteractedStatus = childrenStates.get(lastInteractedId)
|
|
93
|
+
const targetId =
|
|
94
|
+
lastInteractedStatus === STATE.STARTED
|
|
95
|
+
? lastInteractedId
|
|
96
|
+
: findIncompleteLesson(childrenStates, lastInteractedId, content.type)
|
|
97
|
+
const target = childrenById.get(targetId)
|
|
98
|
+
return target ? buildNavigateTo(target) : null
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (GUIDED_FLOW_TYPES.includes(content.type)) {
|
|
102
|
+
const targetId = findIncompleteLesson(childrenStates, lastInteractedId, content.type)
|
|
103
|
+
const target = childrenById.get(targetId)
|
|
104
|
+
return target ? buildNavigateTo(target) : null
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (TWO_DEPTH_TYPES.includes(content.type)) {
|
|
108
|
+
const lastChild = childrenById.get(lastInteractedId)
|
|
109
|
+
if (!lastChild) return null
|
|
110
|
+
const childNav = await computeNavigateTo(lastChild)
|
|
111
|
+
return buildNavigateTo(lastChild, childNav)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return null
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export const navigateToDecorator: FieldDecoratorAsync<
|
|
118
|
+
NavigateToDecoratable,
|
|
119
|
+
typeof NAVIGATE_TO_FIELD,
|
|
120
|
+
NavigateTo | null
|
|
121
|
+
> = {
|
|
122
|
+
field: NAVIGATE_TO_FIELD,
|
|
123
|
+
compute: computeNavigateTo,
|
|
124
|
+
recurse: false,
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function decorateNavigateTo<T extends NavigateToDecoratable>(
|
|
128
|
+
items: T[]
|
|
129
|
+
): Promise<WithNavigateTo<T>[]>
|
|
130
|
+
export function decorateNavigateTo<T extends NavigateToDecoratable>(
|
|
131
|
+
items: T
|
|
132
|
+
): Promise<WithNavigateTo<T>>
|
|
133
|
+
export function decorateNavigateTo<T extends NavigateToDecoratable>(
|
|
134
|
+
items: T | T[]
|
|
135
|
+
): Promise<WithNavigateTo<T> | WithNavigateTo<T>[]> {
|
|
136
|
+
return decorateAllAsync(items as NavigateToDecoratable, [navigateToDecorator]) as Promise<
|
|
137
|
+
WithNavigateTo<T> | WithNavigateTo<T>[]
|
|
138
|
+
>
|
|
139
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { decorate, type Decoratable, type FieldDecorator } from './base'
|
|
2
|
+
import { getPermissionsAdapter, type UserPermissions } from '../../../services/permissions'
|
|
3
|
+
|
|
4
|
+
export const NEED_ACCESS_FIELD = 'need_access' as const
|
|
5
|
+
|
|
6
|
+
export interface AccessDecoratable extends Decoratable {
|
|
7
|
+
permission_id?: number[]
|
|
8
|
+
children?: AccessDecoratable[]
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type WithNeedAccess<T extends AccessDecoratable> = T & {
|
|
12
|
+
need_access: boolean
|
|
13
|
+
children?: WithNeedAccess<NonNullable<T['children']>[number]>[]
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function accessDecorator(
|
|
17
|
+
userPermissions: UserPermissions
|
|
18
|
+
): FieldDecorator<AccessDecoratable, typeof NEED_ACCESS_FIELD, boolean> {
|
|
19
|
+
const adapter = getPermissionsAdapter()
|
|
20
|
+
return {
|
|
21
|
+
field: NEED_ACCESS_FIELD,
|
|
22
|
+
compute: (item) => adapter.doesUserNeedAccess(item, userPermissions),
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function decorateAccess<T extends AccessDecoratable>(
|
|
27
|
+
items: T[],
|
|
28
|
+
userPermissions: UserPermissions
|
|
29
|
+
): WithNeedAccess<T>[]
|
|
30
|
+
export function decorateAccess<T extends AccessDecoratable>(
|
|
31
|
+
items: T,
|
|
32
|
+
userPermissions: UserPermissions
|
|
33
|
+
): WithNeedAccess<T>
|
|
34
|
+
export function decorateAccess<T extends AccessDecoratable>(
|
|
35
|
+
items: T | T[],
|
|
36
|
+
userPermissions: UserPermissions
|
|
37
|
+
): WithNeedAccess<T> | WithNeedAccess<T>[] {
|
|
38
|
+
const { field, compute } = accessDecorator(userPermissions)
|
|
39
|
+
return decorate(items as T, field, compute) as WithNeedAccess<T> | WithNeedAccess<T>[]
|
|
40
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { decorate, type Decoratable, type FieldDecorator } from './base'
|
|
2
|
+
import { SONG_TYPES_WITH_CHILDREN } from '../../../contentTypeConfig.js'
|
|
3
|
+
|
|
4
|
+
export const PAGE_TYPE_FIELD = 'page_type' as const
|
|
5
|
+
|
|
6
|
+
export type PageType = 'song' | 'lesson'
|
|
7
|
+
|
|
8
|
+
export interface PageTypeDecoratable extends Decoratable {
|
|
9
|
+
type?: string
|
|
10
|
+
children?: PageTypeDecoratable[]
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type WithPageType<T extends PageTypeDecoratable> = T & {
|
|
14
|
+
page_type: PageType
|
|
15
|
+
children?: WithPageType<NonNullable<T['children']>[number]>[]
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const pageTypeDecorator: FieldDecorator<
|
|
19
|
+
PageTypeDecoratable,
|
|
20
|
+
typeof PAGE_TYPE_FIELD,
|
|
21
|
+
PageType
|
|
22
|
+
> = {
|
|
23
|
+
field: PAGE_TYPE_FIELD,
|
|
24
|
+
compute: (item) => (SONG_TYPES_WITH_CHILDREN.includes(item.type as string) ? 'song' : 'lesson'),
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function decoratePageType<T extends PageTypeDecoratable>(items: T[]): WithPageType<T>[]
|
|
28
|
+
export function decoratePageType<T extends PageTypeDecoratable>(items: T): WithPageType<T>
|
|
29
|
+
export function decoratePageType<T extends PageTypeDecoratable>(
|
|
30
|
+
items: T | T[]
|
|
31
|
+
): WithPageType<T> | WithPageType<T>[] {
|
|
32
|
+
return decorate(items as T, pageTypeDecorator.field, pageTypeDecorator.compute) as
|
|
33
|
+
| WithPageType<T>
|
|
34
|
+
| WithPageType<T>[]
|
|
35
|
+
}
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* - `getContentAwardsByIds(contentIds)` - Get awards for multiple content items (batch optimized)
|
|
10
10
|
* - `getCompletedAwards(brand)` - Get user's earned awards
|
|
11
11
|
* - `getInProgressAwards(brand)` - Get awards user is working toward
|
|
12
|
+
* - `getCompletedAwardsByUser(userId, brand)` - Get another user's earned awards
|
|
12
13
|
* - `getAwardStatistics(brand)` - Get aggregate award stats
|
|
13
14
|
*
|
|
14
15
|
* **Event Callbacks**:
|
|
@@ -58,6 +59,10 @@ import { AwardMessageGenerator } from './internal/message-generator'
|
|
|
58
59
|
import db from '../sync/repository-proxy'
|
|
59
60
|
import UserAwardProgressRepository from '../sync/repositories/user-award-progress'
|
|
60
61
|
import {awardTemplate} from "../../contentTypeConfig.js";
|
|
62
|
+
import { globalConfig } from '../config.js'
|
|
63
|
+
import { HttpClient } from '../../infrastructure/http/HttpClient'
|
|
64
|
+
|
|
65
|
+
const userManagementBaseUrl = '/api/user-management-system'
|
|
61
66
|
|
|
62
67
|
function enhanceCompletionData(completionData) {
|
|
63
68
|
if (!completionData) return null
|
|
@@ -352,6 +357,72 @@ export async function getCompletedAwards(brand = null, options = {}) {
|
|
|
352
357
|
}
|
|
353
358
|
}
|
|
354
359
|
|
|
360
|
+
/**
|
|
361
|
+
* @param {number|null} [userId=globalConfig.sessionConfig.userId] - The user whose completed awards to fetch
|
|
362
|
+
* @param {string|null} [brand=null] - Brand to filter by (drumeo, pianote, guitareo, singeo), or null for all brands
|
|
363
|
+
* @returns {Promise<AwardInfo[]>} Array of completed award objects sorted by completion date (newest first)
|
|
364
|
+
*
|
|
365
|
+
* @description
|
|
366
|
+
* Returns completed awards for any user (typically used when viewing another
|
|
367
|
+
* user's public profile). Fetches raw progress from the BE then enriches each
|
|
368
|
+
* record with the matching Sanity award definition so the response shape
|
|
369
|
+
* matches `getCompletedAwards`.
|
|
370
|
+
*
|
|
371
|
+
* Returns empty array `[]` on error or when no progress is found.
|
|
372
|
+
*/
|
|
373
|
+
export async function getCompletedAwardsByUser(userId = globalConfig.sessionConfig.userId, brand = null) {
|
|
374
|
+
try {
|
|
375
|
+
const apiUrl = `${userManagementBaseUrl}/v1/users/${userId}/awards`
|
|
376
|
+
const httpClient = new HttpClient(globalConfig.baseUrl, globalConfig.sessionConfig.token)
|
|
377
|
+
const progressRecords = await httpClient.get(apiUrl)
|
|
378
|
+
|
|
379
|
+
if (!Array.isArray(progressRecords) || progressRecords.length === 0) {
|
|
380
|
+
return []
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
let awards = await Promise.all(
|
|
384
|
+
progressRecords.map(async (progress) => {
|
|
385
|
+
const definition = await awardDefinitions.getById(progress.award_id)
|
|
386
|
+
if (!definition) {
|
|
387
|
+
return null
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if (brand && definition.brand !== brand) {
|
|
391
|
+
return null
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const completionData = definition.type === awardDefinitions.CONTENT_AWARD
|
|
395
|
+
? enhanceCompletionData(progress.completion_data)
|
|
396
|
+
: progress.completion_data
|
|
397
|
+
const hasCertificate = definition.type === awardDefinitions.CONTENT_AWARD
|
|
398
|
+
|
|
399
|
+
return {
|
|
400
|
+
awardId: progress.award_id,
|
|
401
|
+
awardTitle: definition.name,
|
|
402
|
+
awardType: definition.type,
|
|
403
|
+
...getBadgeFields(definition),
|
|
404
|
+
award: definition.award,
|
|
405
|
+
brand: definition.brand,
|
|
406
|
+
hasCertificate,
|
|
407
|
+
instructorName: definition.instructor_name,
|
|
408
|
+
progressPercentage: progress.progress_percentage,
|
|
409
|
+
isCompleted: true,
|
|
410
|
+
completedAt: progress.completed_at,
|
|
411
|
+
completionData,
|
|
412
|
+
}
|
|
413
|
+
})
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
awards = awards.filter(award => award !== null)
|
|
417
|
+
awards.sort((a, b) => new Date(b.completedAt).getTime() - new Date(a.completedAt).getTime())
|
|
418
|
+
|
|
419
|
+
return awards
|
|
420
|
+
} catch (error) {
|
|
421
|
+
console.error(`Failed to get completed awards for user ${userId}:`, error)
|
|
422
|
+
return []
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
355
426
|
/**
|
|
356
427
|
* @param {string} [brand=null] - Brand to filter by (drumeo, pianote, guitareo, singeo), or null for all brands
|
|
357
428
|
* @param {AwardPaginationOptions} [options={}] - Optional pagination options
|
|
@@ -317,7 +317,7 @@ function addRecordIdsToData(data, dataField, isDataAnArray, includeParent, inclu
|
|
|
317
317
|
items.push(content)
|
|
318
318
|
recordIds.push(content.record_id)
|
|
319
319
|
}
|
|
320
|
-
if (includeIntroVideo) {
|
|
320
|
+
if (includeIntroVideo && content.intro_video?.id) {
|
|
321
321
|
content.intro_video.record_id = generateRecordId(content.intro_video.id, null)
|
|
322
322
|
items.push(content.intro_video)
|
|
323
323
|
recordIds.push(content.intro_video.record_id)
|
|
@@ -21,12 +21,14 @@ export interface InviteResponse {
|
|
|
21
21
|
is_account_valid: boolean
|
|
22
22
|
is_invite_active: boolean
|
|
23
23
|
can_user_join: boolean
|
|
24
|
+
primary_user_name: string
|
|
25
|
+
product_name: string
|
|
24
26
|
// These fields leak user information and are excluded entirely for the public endpoint
|
|
25
27
|
existing_user_details?: User
|
|
26
28
|
email?: string
|
|
27
29
|
}
|
|
28
30
|
|
|
29
|
-
export interface
|
|
31
|
+
export interface UsersDataForMultiUserAccount {
|
|
30
32
|
user_id: number
|
|
31
33
|
active_multi_user_account: MultiUserAccountResponse
|
|
32
34
|
last_cancelled_multi_user_account: MultiUserAccountResponse
|
|
@@ -40,13 +42,15 @@ export interface MultiUserAccountResponse {
|
|
|
40
42
|
product_name: string
|
|
41
43
|
is_active: boolean
|
|
42
44
|
primary_user: User
|
|
43
|
-
total_seats: number
|
|
44
45
|
end_time: string
|
|
45
46
|
is_primary_account_holder: boolean
|
|
47
|
+
membership_level: 'plus' | 'basic'
|
|
48
|
+
is_lifetime_addon: boolean
|
|
46
49
|
// The following fields are not included for public or subaccount users
|
|
47
|
-
|
|
50
|
+
active_invites?: InviteResponse[]
|
|
48
51
|
available_seats?: number
|
|
49
|
-
available_invites?:
|
|
52
|
+
available_invites?: number
|
|
53
|
+
total_seats?: number
|
|
50
54
|
active_subs?: User[]
|
|
51
55
|
show_welcome?: boolean
|
|
52
56
|
}
|
|
@@ -81,12 +85,12 @@ export async function createAccount(params: CreateAccountParams): Promise<MultiU
|
|
|
81
85
|
* Fetches multi-user account details for a specific user.
|
|
82
86
|
*
|
|
83
87
|
* @param {number} userId - The ID of the user to fetch account details for.
|
|
84
|
-
* @returns {Promise<
|
|
88
|
+
* @returns {Promise<UsersDataForMultiUserAccount>} - A promise that resolves to the account details.
|
|
85
89
|
* @throws {HttpError} - If the HTTP request fails.
|
|
86
90
|
*/
|
|
87
|
-
export async function fetchUsersMultiAccountDetails(userId: number): Promise<
|
|
91
|
+
export async function fetchUsersMultiAccountDetails(userId: number): Promise<UsersDataForMultiUserAccount> {
|
|
88
92
|
const httpClient = new HttpClient(globalConfig.baseUrl)
|
|
89
|
-
return httpClient.get<
|
|
93
|
+
return httpClient.get<UsersDataForMultiUserAccount>(`${baseUrl}/${userId}/details`)
|
|
90
94
|
}
|
|
91
95
|
|
|
92
96
|
|