musora-content-services 2.185.0 → 2.186.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.
@@ -0,0 +1,178 @@
1
+ import { GET, POST } from '../../infrastructure/http/HttpClient'
2
+ import { decorateAsync } from '../../lib/sanity/decorators/base'
3
+ import type { Decoratable, FieldDecoratorAsync } from '../../lib/sanity/decorators/base'
4
+ import { Filters as f } from '../../lib/sanity/filter'
5
+ import { groq } from '../../lib/sanity/groq'
6
+ import type { GroqBuilder } from '../../lib/sanity/groq'
7
+ import type { PromotionalCampaignDocument } from '../../lib/sanity/types/marketing'
8
+ import { SanityQueryError } from '@/lib/sanity/runner'
9
+ import { AsyncEither } from '@/lib/ads/async-either'
10
+
11
+ const excludeFromGeneratedIndex = [
12
+ 'campaignQuery',
13
+ 'campaignProjection',
14
+ 'userCampaignStateDecorator',
15
+ 'decorateUserCampaignState',
16
+ ]
17
+
18
+ // ============================================
19
+ // TYPES
20
+ // ============================================
21
+
22
+ export const USER_CAMPAIGN_STATE_FIELD = 'user_state' as const
23
+
24
+ /**
25
+ * A campaign's placements are authored in Sanity, but whether a user still sees one is answered by
26
+ * the backend. `decorateUserCampaignState` attaches the second to the first.
27
+ */
28
+ export interface UserCampaignState {
29
+ claimed: boolean
30
+ dismissed: boolean
31
+ eligible: boolean
32
+ }
33
+
34
+ export type WithUserCampaignState<T extends PromotionalCampaignDocument> = T & {
35
+ user_state: UserCampaignState
36
+ }
37
+
38
+ export interface CampaignQueryOptions {
39
+ /** Restricts the query to one campaign, or to any of several. */
40
+ slug?: string | string[]
41
+ /** The moment the campaign window is tested against. Defaults to now. */
42
+ on?: Date
43
+ /** Set false to return campaigns whose window does not cover `on`. */
44
+ running?: boolean
45
+ /** Set false to include campaigns that are toggled off in Sanity. */
46
+ active?: boolean
47
+ }
48
+
49
+ const UNTOUCHED: UserCampaignState = { claimed: false, dismissed: false, eligible: true }
50
+
51
+ // ============================================
52
+ // UTILITIES
53
+ // ============================================
54
+
55
+ const filterCampaignsForUser = (campaigns: WithUserCampaignState<PromotionalCampaignDocument>[]) =>
56
+ campaigns.filter(
57
+ (campaign) =>
58
+ campaign.user_state.eligible && !campaign.user_state.claimed && !campaign.user_state.dismissed
59
+ )
60
+
61
+ // ============================================
62
+ // QUERY BUILDING
63
+ // ============================================
64
+
65
+ export const campaignProjection: string[] = [
66
+ '...',
67
+ 'placements[]{ ..., "dark_mode_image": dark_mode_image.asset->url, "light_mode_image": light_mode_image.asset->url }',
68
+ ]
69
+
70
+ function slugFilter(slug: CampaignQueryOptions['slug']): string {
71
+ const slugs = (Array.isArray(slug) ? slug : [slug]).filter(Boolean) as string[]
72
+
73
+ if (!slugs.length) return f.empty
74
+
75
+ return slugs.length === 1 ? f.slug(slugs[0]) : f.slugIn(slugs)
76
+ }
77
+
78
+ export function campaignQuery(
79
+ options: CampaignQueryOptions = {}
80
+ ): GroqBuilder<PromotionalCampaignDocument[]> {
81
+ const { slug, on = new Date(), running = true, active = true } = options
82
+
83
+ return groq<PromotionalCampaignDocument[]>()
84
+ .and(
85
+ f.combine(
86
+ f.type('promotional-campaign'),
87
+ active && f.flag('active'),
88
+ running && f.dateWithin('starts_at', 'ends_at', on.toISOString()),
89
+ slugFilter(slug)
90
+ )
91
+ )
92
+ .order('starts_at desc')
93
+ .select(...campaignProjection)
94
+ .default([])
95
+ }
96
+
97
+ // ============================================
98
+ // FETCHING
99
+ // ============================================
100
+
101
+ export async function fetchPromotionalCampaigns(
102
+ options: CampaignQueryOptions = {}
103
+ ): Promise<PromotionalCampaignDocument[]> {
104
+ return campaignQuery(options).run().recover([])
105
+ }
106
+
107
+ /**
108
+ * Every running campaign the user has not already claimed or dismissed, and is eligible.
109
+ */
110
+ export async function fetchAvailablePromotionalCampaigns(
111
+ options: CampaignQueryOptions = {}
112
+ ): Promise<WithUserCampaignState<PromotionalCampaignDocument>[]> {
113
+ return campaignQuery(options)
114
+ .run()
115
+ .flatMap(decorateUserCampaignState)
116
+ .map(filterCampaignsForUser)
117
+ .recover([])
118
+ }
119
+
120
+ /**
121
+ * Fetches the state of every given campaign in a single request, keyed by slug. The backend leaves
122
+ * out slugs it has no campaign for, so a missing key means the campaign is unknown to it.
123
+ */
124
+ export async function fetchUserCampaignStates(
125
+ slugs: string[]
126
+ ): Promise<Record<string, UserCampaignState>> {
127
+ if (!slugs.length) return {}
128
+
129
+ const query = slugs.map((slug) => `slugs[]=${encodeURIComponent(slug)}`).join('&')
130
+
131
+ return (await GET(`/api/promotions/v1/campaigns/state?${query}`)) ?? {}
132
+ }
133
+
134
+ // ============================================
135
+ // DECORATION
136
+ // ============================================
137
+
138
+ export function userCampaignStateDecorator(
139
+ states: Record<string, UserCampaignState>
140
+ ): FieldDecoratorAsync<Decoratable, typeof USER_CAMPAIGN_STATE_FIELD, UserCampaignState> {
141
+ return {
142
+ field: USER_CAMPAIGN_STATE_FIELD,
143
+ compute: async (item) => {
144
+ const campaign = item as unknown as PromotionalCampaignDocument
145
+
146
+ return states[campaign.slug?.current] ?? UNTOUCHED
147
+ },
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Attaches each campaign's claim and dismiss state in a single backend request.
153
+ */
154
+ export function decorateUserCampaignState<T extends PromotionalCampaignDocument>(
155
+ campaigns: T[]
156
+ ): AsyncEither<Error, WithUserCampaignState<T>[]> {
157
+ return AsyncEither.attempt(async () => {
158
+ if (!campaigns.length) return []
159
+
160
+ const states = await fetchUserCampaignStates(campaigns.map((campaign) => campaign.slug.current))
161
+ const { field, compute } = userCampaignStateDecorator(states)
162
+ const decorated = await decorateAsync(campaigns as unknown as Decoratable[], field, compute)
163
+
164
+ return decorated as unknown as WithUserCampaignState<T>[]
165
+ })
166
+ }
167
+
168
+ // ============================================
169
+ // MUTATIONS
170
+ // ============================================
171
+
172
+ export async function claimPromotionalCampaign(slug: string): Promise<void> {
173
+ await POST(`/api/promotions/v1/campaigns/${encodeURIComponent(slug)}/claim`)
174
+ }
175
+
176
+ export async function dismissPromotionalCampaign(slug: string): Promise<void> {
177
+ await POST(`/api/promotions/v1/campaigns/${encodeURIComponent(slug)}/dismiss`)
178
+ }
@@ -0,0 +1,307 @@
1
+ /**
2
+ * @module Recommendations
3
+ */
4
+
5
+ import { TabResponseType } from '../contentMetaData.js'
6
+ import { getFieldsForContentTypeWithFilteredChildren } from '../contentTypeConfig.js'
7
+ import { decorateAll, type FieldDecorator } from '../lib/sanity/decorators/base'
8
+ import { accessDecorator, type WithNeedAccess } from '../lib/sanity/decorators/need-access'
9
+ import {
10
+ lifetimeUpgradeDecorator,
11
+ type WithNeedLifetimeUpgrade,
12
+ } from '../lib/sanity/decorators/need-lifetime-upgrade'
13
+ import { decorateNavigateTo, type WithNavigateTo } from '../lib/sanity/decorators/navigate-to'
14
+ import { pageTypeDecorator, type WithPageType } from '../lib/sanity/decorators/page-type'
15
+ import { Filters as f } from '../lib/sanity/filter'
16
+ import { groq } from '../lib/sanity/groq'
17
+ import { globalConfig } from './config.js'
18
+ import { GET, HttpClient } from '../infrastructure/http/HttpClient'
19
+ import { fetchUserPermissions, UserPermissions } from './permissions/index'
20
+
21
+ /**
22
+ * @type {string[]}
23
+ */
24
+ const excludeFromGeneratedIndex = []
25
+
26
+ const RECOMMENDED_ROW_ID = 'recommended'
27
+ const RECOMMENDED_CONTENT_TYPE = 'tab-data'
28
+
29
+ const RECOMMENDER_URL = 'https://recommender.musora.com'
30
+ const recommenderClient = new HttpClient(RECOMMENDER_URL, null, null, null, 'omit')
31
+
32
+ interface RecommendedContent {
33
+ id: number
34
+ type: string
35
+ brand: string
36
+ thumbnail: string
37
+ published_on: string | null
38
+ status: string
39
+ live_event_start_time?: string | null
40
+ live_event_end_time?: string | null
41
+ children?: RecommendedContent[]
42
+ [key: string]: unknown
43
+ }
44
+
45
+ type DecoratedRecommendedContent = WithNeedLifetimeUpgrade<
46
+ WithPageType<WithNeedAccess<RecommendedContent>>
47
+ > & { isLive: boolean }
48
+
49
+ type NavigableRecommendedContent = WithNavigateTo<DecoratedRecommendedContent>
50
+
51
+ interface RecommendedRow {
52
+ id: string
53
+ title: string
54
+ items: NavigableRecommendedContent[]
55
+ }
56
+
57
+ interface RecommendedCatalog {
58
+ type: string
59
+ data: NavigableRecommendedContent[]
60
+ meta: Record<string, never>
61
+ }
62
+
63
+ export interface RecommendedForYouOptions {
64
+ page?: number
65
+ limit?: number
66
+ }
67
+
68
+ export interface RecommendationsOptions {
69
+ section?: string
70
+ contentTypes?: string[]
71
+ }
72
+
73
+ export interface RankedCategory {
74
+ slug: string
75
+ items: number[]
76
+ }
77
+
78
+ export type CategoriesToRank = Record<string, number[]>
79
+
80
+ /**
81
+ * @param {number|string} contentId
82
+ * @param {string} brand
83
+ * @param {number} [count=10]
84
+ * @returns {Promise<number[]|null>}
85
+ * @example
86
+ * fetchSimilarItems(1113, 'drumeo')
87
+ * .then(ids => console.log(ids))
88
+ * .catch(error => console.error(error));
89
+ */
90
+ export async function fetchSimilarItems(
91
+ contentId: number | string,
92
+ brand: string,
93
+ count: number = 10
94
+ ): Promise<number[] | null> {
95
+ if (!contentId) {
96
+ return []
97
+ }
98
+ const id = parseInt(String(contentId))
99
+ const data = {
100
+ brand,
101
+ content_ids: id,
102
+ num_similar: count + 1,
103
+ page_size: count + 1,
104
+ page: 1,
105
+ exclude_interacted: true,
106
+ }
107
+ try {
108
+ const response = await recommenderClient.post('/similar_items/', data)
109
+ return response['similar_items'].filter((item: number) => item !== id).slice(0, count)
110
+ } catch (error) {
111
+ console.error('Fetch error:', error)
112
+ return null
113
+ }
114
+ }
115
+
116
+ /**
117
+ * @param {string} brand
118
+ * @param {CategoriesToRank} categories
119
+ * @returns {Promise<RankedCategory[]>}
120
+ * @example
121
+ * rankCategories('drumeo', { 1: [111222, 23120], 2: [2222, 33333] })
122
+ * .then(categories => console.log(categories))
123
+ * .catch(error => console.error(error));
124
+ */
125
+ export async function rankCategories(
126
+ brand: string,
127
+ categories: CategoriesToRank
128
+ ): Promise<RankedCategory[]> {
129
+ const data = {
130
+ brand,
131
+ user_id: globalConfig.sessionConfig.userId,
132
+ playlists: categories,
133
+ }
134
+ try {
135
+ const response = await recommenderClient.post('/rank_each_list/', data)
136
+ return response['ranked_playlists'].map((rankedPlaylist: any) => ({
137
+ slug: rankedPlaylist.playlist_id,
138
+ items: rankedPlaylist.ranked_items,
139
+ }))
140
+ } catch (error) {
141
+ console.error('RankCategories fetch error:', error)
142
+ }
143
+
144
+ return Object.entries(categories).map(([slug, items]) => ({ slug, items }))
145
+ }
146
+
147
+ /**
148
+ * @param {string} brand
149
+ * @param {number[]} contentIds
150
+ * @returns {Promise<number[]>}
151
+ * @example
152
+ * rankItems('drumeo', [111222, 23120, 402199])
153
+ * .then(ids => console.log(ids))
154
+ * .catch(error => console.error(error));
155
+ */
156
+ export async function rankItems(brand: string, contentIds: number[]): Promise<number[]> {
157
+ if (contentIds.length === 0) {
158
+ return []
159
+ }
160
+ const data = {
161
+ brand,
162
+ user_id: globalConfig.sessionConfig.userId,
163
+ content_ids: contentIds,
164
+ }
165
+ try {
166
+ const response = await recommenderClient.post('/rank_items/', data)
167
+ return response['ranked_content_ids']
168
+ } catch (error) {
169
+ console.error('rankItems fetch error:', error)
170
+ return contentIds
171
+ }
172
+ }
173
+
174
+ /**
175
+ * @param {string} brand
176
+ * @param {RecommendationsOptions} [options={}]
177
+ * @returns {Promise<number[]>}
178
+ * @example
179
+ * recommendations('drumeo', { section: 'lessons', contentTypes: ['course'] })
180
+ * .then(ids => console.log(ids))
181
+ * .catch(error => console.error(error));
182
+ */
183
+ export async function recommendations(
184
+ brand: string,
185
+ { section = '', contentTypes = [] }: RecommendationsOptions = {}
186
+ ): Promise<number[]> {
187
+ const sectionParam = section.toUpperCase().replace('-', '_')
188
+ const sectionString = sectionParam ? `&section=${sectionParam}` : ''
189
+ const contentTypesString = contentTypes
190
+ .map((type) => `&content_types[]=${encodeURIComponent(type)}`)
191
+ .join('')
192
+
193
+ return await GET(
194
+ `/api/content/v1/recommendations?brand=${brand}${sectionString}${contentTypesString}`
195
+ )
196
+ }
197
+
198
+ const isLiveDecorator: FieldDecorator<RecommendedContent, 'isLive', boolean> = {
199
+ field: 'isLive',
200
+ recurse: false,
201
+ compute: (content) => {
202
+ const now = new Date().toISOString()
203
+ return Boolean(
204
+ content.live_event_start_time &&
205
+ content.live_event_end_time &&
206
+ content.live_event_start_time <= now &&
207
+ content.live_event_end_time >= now
208
+ )
209
+ },
210
+ }
211
+
212
+ const uniqueIds = (ids: Array<number | null | undefined>): number[] => [
213
+ ...new Set(ids.filter((id) => id !== null && id !== undefined) as number[]),
214
+ ]
215
+
216
+ const sortByRecommendedOrder =
217
+ (ids: number[]) =>
218
+ <T extends { id: number }>(content: T[]): T[] =>
219
+ content.sort((a, b) => ids.indexOf(a.id) - ids.indexOf(b.id))
220
+
221
+ const decorateRecommendations =
222
+ (permissions: UserPermissions) =>
223
+ (contents: RecommendedContent[]): DecoratedRecommendedContent[] =>
224
+ decorateAll(contents, [
225
+ accessDecorator(permissions),
226
+ lifetimeUpgradeDecorator(permissions),
227
+ pageTypeDecorator,
228
+ isLiveDecorator,
229
+ ]) as DecoratedRecommendedContent[]
230
+
231
+ async function fetchRecommendedContent(
232
+ ids: number[],
233
+ brand: string
234
+ ): Promise<NavigableRecommendedContent[]> {
235
+ if (!ids.length) return []
236
+
237
+ const [restrictions, lessonCountFilter, fields, permissions] = await Promise.all([
238
+ f.combineAsync(
239
+ f.idIn(ids),
240
+ brand ? f.brand(brand) : f.empty,
241
+ f.status(),
242
+ f.notDeprecated(),
243
+ f.permissions({ showMembershipRestrictedContent: true })
244
+ ),
245
+ f.combineAsync('_id in ^.child[]._ref', f.status(), f.notDeprecated(), f.permissions()),
246
+ getFieldsForContentTypeWithFilteredChildren(RECOMMENDED_CONTENT_TYPE, false) as Promise<
247
+ string[]
248
+ >,
249
+ fetchUserPermissions(),
250
+ ])
251
+
252
+ return groq<RecommendedContent[]>()
253
+ .and(restrictions)
254
+ .select(
255
+ ...fields,
256
+ `"lesson_count": coalesce(${f.count(lessonCountFilter)}, 0)`,
257
+ 'live_event_start_time',
258
+ 'live_event_end_time'
259
+ )
260
+ .default([])
261
+ .run()
262
+ .map(decorateRecommendations(permissions))
263
+ .mapAsync((contents) => decorateNavigateTo(contents))
264
+ .map(sortByRecommendedOrder(ids))
265
+ .recover([])
266
+ }
267
+
268
+ /**
269
+ * @param {string} brand
270
+ * @param {string|null} [rowId=null]
271
+ * @param {Object} [params={}]
272
+ * @param {number} [params.page=1]
273
+ * @param {number} [params.limit=10]
274
+ * @returns {Promise<RecommendedRow|RecommendedCatalog>}
275
+ * @example
276
+ * getRecommendedForYou('drumeo')
277
+ * .then(content => console.log(content))
278
+ * .catch(error => console.error(error));
279
+ * @example
280
+ * getRecommendedForYou('drumeo', null, { page: 2, limit: 5 })
281
+ * .then(content => console.log(content))
282
+ * .catch(error => console.error(error));
283
+ */
284
+ export async function getRecommendedForYou(
285
+ brand: string,
286
+ rowId: string | null = null,
287
+ { page = 1, limit = 10 }: RecommendedForYouOptions = {}
288
+ ): Promise<RecommendedRow | RecommendedCatalog> {
289
+ const title = brand === 'playbass' ? 'You Might Like' : 'Recommended For You'
290
+ const data = await recommendations(brand)
291
+
292
+ if (!data || !data.length) {
293
+ return { id: RECOMMENDED_ROW_ID, title, items: [] }
294
+ }
295
+
296
+ const offset = (page - 1) * limit
297
+ const contents = await fetchRecommendedContent(
298
+ uniqueIds(data.slice(offset, offset + limit)),
299
+ brand
300
+ )
301
+
302
+ if (rowId) {
303
+ return { type: TabResponseType.CATALOG, data: contents, meta: {} }
304
+ }
305
+
306
+ return { id: RECOMMENDED_ROW_ID, title, items: contents }
307
+ }
@@ -33,7 +33,7 @@ import {
33
33
  transcriptionsLessonTypes,
34
34
  tutorialsLessonTypes,
35
35
  } from '../contentTypeConfig.js'
36
- import { fetchSimilarItems } from './recommendations.js'
36
+ import { fetchSimilarItems } from './recommendations'
37
37
  import {
38
38
  ALWAYS_VISIBLE_TABS,
39
39
  CONTENT_STATUSES,
@@ -9,6 +9,8 @@ import { Onboarding } from './onboarding'
9
9
  import { OAuthProvider } from './session'
10
10
  import { AuthResponse } from './types'
11
11
 
12
+ const LOCAL_PART_MASK = '***'
13
+
12
14
  export interface AccountStatus {
13
15
  requires_setup: boolean
14
16
  last_login_provider?: OAuthProvider
@@ -256,3 +258,24 @@ export async function toggleStudentView(useStudentView: boolean): Promise<UserRe
256
258
  const httpClient = new HttpClient(globalConfig.baseUrl, globalConfig.sessionConfig.token)
257
259
  return httpClient.patch<UserResource>(apiUrl, { use_student_view: useStudentView })
258
260
  }
261
+
262
+ /**
263
+ * Masks the local part of an email, keeping the domain intact.
264
+ * Shows the first character of the local part unless it's 3 characters or shorter,
265
+ * in which case the whole local part is masked.
266
+ * @example
267
+ * maskEmail('abcd@gmail.com') // 'a***@gmail.com'
268
+ * maskEmail('ab@abc.com') // '***@abc.com'
269
+ */
270
+ export function maskEmail(email: string): string {
271
+ const atIndex = email.indexOf('@')
272
+ if (atIndex === -1) {
273
+ return email
274
+ }
275
+ const localPart = email.slice(0, atIndex)
276
+ const domain = email.slice(atIndex)
277
+
278
+ return localPart.length <= 3
279
+ ? `${LOCAL_PART_MASK}${domain}`
280
+ : `${localPart[0]}${LOCAL_PART_MASK}${domain}`
281
+ }
@@ -1 +1 @@
1
- export const MCS_VERSION = '2.185.0'
1
+ export const MCS_VERSION = '2.186.0'
Binary file
@@ -1,2 +0,0 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
@@ -1,142 +0,0 @@
1
- /**
2
- * @module Railcontent-Services
3
- */
4
-
5
- import { globalConfig } from './config.js'
6
- import { GET, HttpClient } from '../infrastructure/http/HttpClient.ts'
7
-
8
- /**
9
- * Exported functions that are excluded from index generation.
10
- *
11
- * @type {string[]}
12
- */
13
- const excludeFromGeneratedIndex = []
14
-
15
- const RECOMMENDER_URL = 'https://recommender.musora.com'
16
- const recommenderClient = new HttpClient(RECOMMENDER_URL, null, null, null, 'omit')
17
-
18
- /**
19
- * Fetches similar content to the provided content id
20
- *
21
- * @param {integer} content_id - The ID of the content to find similar items for
22
- * @param {brand} brand - brand of the content to filter
23
- * @param {integer} count - number of items to return
24
- * @returns {Promise<Object|null>} - Returns the content_ids sorted by rank (most significant first)
25
- * @example
26
- * rankItems('drumeo', 1113)
27
- * .then(status => console.log(status))
28
- * .catch(error => console.error(error));
29
- */
30
- export async function fetchSimilarItems(content_id, brand, count = 10) {
31
- if (!content_id) {
32
- return []
33
- }
34
- content_id = parseInt(content_id)
35
- const data = {
36
- brand: brand,
37
- content_ids: content_id,
38
- num_similar: count + 1,
39
- page_size: count + 1,
40
- page: 1,
41
- exclude_interacted: true,
42
- }
43
- const url = `/similar_items/`
44
- try {
45
- const response = await recommenderClient.post(url, data)
46
- return response['similar_items'].filter((item) => item !== content_id).slice(0, count)
47
- } catch (error) {
48
- console.error('Fetch error:', error)
49
- return null
50
- }
51
- }
52
-
53
- /**
54
- * Sorts the provided categories based on the user's match
55
- *
56
- * @param {brand} brand - brand of the content to filter
57
- * @param {Object} categories - Keyed arrays of content ids
58
- * @returns {Promise<Object|null>} - Returns the content_ids sorted by rank (most significant first), elements in each category are also ranked
59
- * @example
60
- * rankCategories('drumeo', {
61
- * 1: [111222, 23120, 402199],
62
- * 2: [2222, 33333, 44444]
63
- * }
64
- * )
65
- * .then(status => console.log(status))
66
- * .catch(error => console.error(error));
67
- */
68
- export async function rankCategories(brand, categories) {
69
- if (categories.length === 0) {
70
- return []
71
- }
72
- const data = {
73
- brand: brand,
74
- user_id: globalConfig.sessionConfig.userId,
75
- playlists: categories,
76
- }
77
- const url = `/rank_each_list/`
78
- try {
79
- const response = await recommenderClient.post(url, data)
80
- const rankedCategories = []
81
-
82
- for (const rankedPlaylist of response['ranked_playlists']) {
83
- rankedCategories.push({
84
- slug: rankedPlaylist.playlist_id,
85
- items: rankedPlaylist.ranked_items,
86
- })
87
- }
88
- return rankedCategories
89
- } catch (error) {
90
- console.error('RankCategories fetch error:', error)
91
- }
92
-
93
- const defaultSorting = []
94
- for (const slug in categories) {
95
- defaultSorting.push({
96
- slug: slug,
97
- items: categories[slug],
98
- })
99
- }
100
- return defaultSorting
101
- }
102
-
103
- /**
104
- * Fetches the completion status of a specific lesson for the current user.
105
- *
106
- * @param {brand} brand - brand of the content to filter
107
- * @param {Array<string>} content_ids - The IDs of the content to rank
108
- * @returns {Promise<Object|null>} - Returns the content_ids sorted by rank (most significant first)
109
- * @example
110
- * rankItems('drumeo', ([111222, 23120, 402199])
111
- * .then(status => console.log(status))
112
- * .catch(error => console.error(error));
113
- */
114
- export async function rankItems(brand, content_ids) {
115
- if (content_ids.length === 0) {
116
- return []
117
- }
118
- const data = {
119
- brand: brand,
120
- user_id: globalConfig.sessionConfig.userId,
121
- content_ids: content_ids,
122
- }
123
- const url = `/rank_items/`
124
- try {
125
- const response = await recommenderClient.post(url, data)
126
- return response['ranked_content_ids']
127
- } catch (error) {
128
- console.error('rankItems fetch error:', error)
129
- return content_ids
130
- }
131
- }
132
-
133
- export async function recommendations(brand, { section = '', contentTypes = [] } = {}) {
134
- section = section.toUpperCase().replace('-', '_')
135
- const sectionString = section ? `&section=${section}` : ''
136
- const contentTypesString =
137
- contentTypes.length > 0
138
- ? contentTypes.map((type) => `&content_types[]=${encodeURIComponent(type)}`).join('')
139
- : ''
140
- const url = `/api/content/v1/recommendations?brand=${brand}${sectionString}${contentTypesString}`
141
- return await GET(url)
142
- }