musora-content-services 2.81.0 → 2.83.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 (95) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/docs/ContentOrganization.html +2 -2
  3. package/docs/Forums.html +2 -2
  4. package/docs/Gamification.html +2 -2
  5. package/docs/TestUser.html +2 -2
  6. package/docs/UserManagementSystem.html +2 -2
  7. package/docs/api_types.js.html +2 -2
  8. package/docs/config.js.html +5 -2
  9. package/docs/content-org_content-org.js.html +2 -2
  10. package/docs/content-org_guided-courses.ts.html +2 -2
  11. package/docs/content-org_learning-paths.ts.html +2 -2
  12. package/docs/content-org_playlists-types.js.html +2 -2
  13. package/docs/content-org_playlists.js.html +2 -2
  14. package/docs/content.js.html +88 -10
  15. package/docs/content_artist.ts.html +8 -8
  16. package/docs/content_genre.ts.html +18 -15
  17. package/docs/content_instructor.ts.html +21 -16
  18. package/docs/forums_categories.ts.html +21 -2
  19. package/docs/forums_forums.ts.html +2 -2
  20. package/docs/forums_posts.ts.html +2 -2
  21. package/docs/forums_threads.ts.html +2 -2
  22. package/docs/gamification_awards.ts.html +2 -2
  23. package/docs/gamification_gamification.js.html +2 -2
  24. package/docs/global.html +2 -2
  25. package/docs/index.html +2 -2
  26. package/docs/liveTesting.ts.html +2 -2
  27. package/docs/module-Accounts.html +2 -2
  28. package/docs/module-Artist.html +8 -8
  29. package/docs/module-Awards.html +2 -2
  30. package/docs/module-Config.html +5 -4
  31. package/docs/module-Content-Services-V2.html +440 -9
  32. package/docs/module-Forums.html +607 -43
  33. package/docs/module-Genre.html +9 -9
  34. package/docs/module-GuidedCourses.html +2 -2
  35. package/docs/module-Instructor.html +6 -6
  36. package/docs/module-Interests.html +2 -2
  37. package/docs/module-LearningPaths.html +2 -2
  38. package/docs/module-Onboarding.html +2 -2
  39. package/docs/module-Payments.html +2 -2
  40. package/docs/module-Permissions.html +2 -2
  41. package/docs/module-Playlists.html +2 -2
  42. package/docs/module-ProgressRow.html +2 -2
  43. package/docs/module-Railcontent-Services.html +2 -2
  44. package/docs/module-Sanity-Services.html +320 -15
  45. package/docs/module-Sessions.html +2 -2
  46. package/docs/module-UserActivity.html +2 -2
  47. package/docs/module-UserChat.html +2 -2
  48. package/docs/module-UserManagement.html +2 -2
  49. package/docs/module-UserMemberships.html +2 -2
  50. package/docs/module-UserNotifications.html +2 -2
  51. package/docs/module-UserProfile.html +2 -2
  52. package/docs/progress-row_method-card.js.html +2 -2
  53. package/docs/railcontent.js.html +2 -2
  54. package/docs/sanity.js.html +105 -42
  55. package/docs/userActivity.js.html +2 -2
  56. package/docs/user_account.ts.html +2 -2
  57. package/docs/user_chat.js.html +2 -2
  58. package/docs/user_interests.js.html +2 -2
  59. package/docs/user_management.js.html +2 -2
  60. package/docs/user_memberships.ts.html +2 -2
  61. package/docs/user_notifications.js.html +2 -2
  62. package/docs/user_onboarding.ts.html +2 -2
  63. package/docs/user_payments.ts.html +2 -2
  64. package/docs/user_permissions.js.html +3 -3
  65. package/docs/user_profile.js.html +2 -2
  66. package/docs/user_sessions.js.html +2 -2
  67. package/docs/user_types.js.html +2 -2
  68. package/docs/user_user-management-system.js.html +2 -2
  69. package/package.json +1 -1
  70. package/src/contentTypeConfig.js +33 -1
  71. package/src/filterBuilder.js +22 -12
  72. package/src/index.d.ts +6 -0
  73. package/src/index.js +6 -0
  74. package/src/lib/lastUpdated.js +4 -4
  75. package/src/services/config.js +3 -0
  76. package/src/services/content/artist.ts +6 -6
  77. package/src/services/content/genre.ts +16 -13
  78. package/src/services/content/instructor.ts +19 -14
  79. package/src/services/content.js +86 -8
  80. package/src/services/contentAggregator.js +4 -4
  81. package/src/services/contentProgress.js +16 -3
  82. package/src/services/forums/categories.ts +19 -0
  83. package/src/services/permissions/PermissionsAdapter.ts +111 -0
  84. package/src/services/permissions/PermissionsAdapterFactory.ts +71 -0
  85. package/src/services/permissions/PermissionsV1Adapter.ts +232 -0
  86. package/src/services/permissions/PermissionsV2Adapter.ts +226 -0
  87. package/src/services/permissions/README.md +139 -0
  88. package/src/services/permissions/index.ts +65 -0
  89. package/src/services/sanity.js +103 -40
  90. package/src/services/types.js +1 -0
  91. package/src/services/user/permissions.js +1 -1
  92. package/test/content.test.js +5 -0
  93. package/test/forum.test.js +1 -1
  94. package/test/initializeTests.js +5 -3
  95. package/tools/generate-index.cjs +5 -0
package/src/index.js CHANGED
@@ -68,6 +68,7 @@ import {
68
68
  getLegacyMethods,
69
69
  getLessonContentRows,
70
70
  getNewAndUpcoming,
71
+ getOwnedContent,
71
72
  getRecent,
72
73
  getRecommendedForYou,
73
74
  getScheduleContentRows,
@@ -124,6 +125,7 @@ import {
124
125
 
125
126
  import {
126
127
  createForumCategory,
128
+ deleteForumCategory,
127
129
  fetchForumCategories,
128
130
  updateForumCategory
129
131
  } from './services/forums/categories.ts';
@@ -270,6 +272,7 @@ import {
270
272
  fetchNewReleases,
271
273
  fetchNextPreviousLesson,
272
274
  fetchOtherSongVersions,
275
+ fetchOwnedContent,
273
276
  fetchPackAll,
274
277
  fetchPackData,
275
278
  fetchPlayAlongsCount,
@@ -441,6 +444,7 @@ export {
441
444
  createThread,
442
445
  deleteAccount,
443
446
  deleteComment,
447
+ deleteForumCategory,
444
448
  deleteItemsFromPlaylist,
445
449
  deleteNotification,
446
450
  deletePicture,
@@ -518,6 +522,7 @@ export {
518
522
  fetchNotificationSettings,
519
523
  fetchNotifications,
520
524
  fetchOtherSongVersions,
525
+ fetchOwnedContent,
521
526
  fetchPackAll,
522
527
  fetchPackData,
523
528
  fetchPlayAlongsCount,
@@ -580,6 +585,7 @@ export {
580
585
  getNewAndUpcoming,
581
586
  getNextLesson,
582
587
  getOnboardingRecommendedContent,
588
+ getOwnedContent,
583
589
  getPracticeNotes,
584
590
  getPracticeSessions,
585
591
  getProgressDateByIds,
@@ -15,8 +15,8 @@ const excludeFromGeneratedIndex = ['wasLastUpdateOlderThanXSeconds', 'setLastUpd
15
15
  *
16
16
  * @returns {boolean} - True if the last update was older than X seconds, false otherwise.
17
17
  */
18
- export function wasLastUpdateOlderThanXSeconds(seconds, key) {
19
- let lastUpdated = globalConfig.localStorage.getItem(key)
18
+ export async function wasLastUpdateOlderThanXSeconds(seconds, key) {
19
+ let lastUpdated = await globalConfig.localStorage.getItem(key)
20
20
  if (!lastUpdated) return false
21
21
  const verifyServerTime = seconds * 1000
22
22
  return new Date().getTime() - lastUpdated > verifyServerTime
@@ -29,6 +29,6 @@ export function wasLastUpdateOlderThanXSeconds(seconds, key) {
29
29
  *
30
30
  * @returns {void}
31
31
  */
32
- export function setLastUpdatedTime(key) {
33
- globalConfig.localStorage.setItem(key, new Date().getTime()?.toString())
32
+ export async function setLastUpdatedTime(key) {
33
+ await globalConfig.localStorage.setItem(key, new Date().getTime()?.toString())
34
34
  }
@@ -12,6 +12,7 @@ export let globalConfig = {
12
12
  localStorage: null,
13
13
  isMA: false,
14
14
  localTimezoneString: null, // In format: America/Vancouver
15
+ permissionsVersion: 'v1', // 'v1' or 'v2'
15
16
  }
16
17
 
17
18
  /**
@@ -52,6 +53,7 @@ const excludeFromGeneratedIndex = []
52
53
  * baseUrl: 'https://web-staging-one.musora.com',
53
54
  * localStorage: localStorage,
54
55
  * isMA: false,
56
+ * permissionsVersion: 'v1', // Optional: 'v1' (default) or 'v2'
55
57
  * });
56
58
  */
57
59
  export function initializeService(config) {
@@ -62,4 +64,5 @@ export function initializeService(config) {
62
64
  globalConfig.localStorage = config.localStorage
63
65
  globalConfig.isMA = config.isMA || false
64
66
  globalConfig.localTimezoneString = config.localTimezoneString || null
67
+ globalConfig.permissionsVersion = config.permissionsVersion || 'v2'
65
68
  }
@@ -74,13 +74,13 @@ export interface ArtistLessonOptions {
74
74
  }
75
75
 
76
76
  export interface LessonsByArtistResponse {
77
- entity: Artist[]
77
+ data: Artist[]
78
78
  }
79
79
 
80
80
  /**
81
81
  * Fetch the artist's lessons.
82
- * @param {string} brand - The brand for which to fetch lessons.
83
82
  * @param {string} slug - The slug of the artist
83
+ * @param {string} brand - The brand for which to fetch lessons.
84
84
  * @param {string} contentType - The type of the lessons we need to get from the artist. If not defined, groq will get lessons from all content types
85
85
  * @param {Object} params - Parameters for sorting, searching, pagination and filtering.
86
86
  * @param {string} [params.sort="-published_on"] - The field to sort the lessons by.
@@ -92,13 +92,13 @@ export interface LessonsByArtistResponse {
92
92
  * @returns {Promise<LessonsByArtistResponse|null>} - The lessons for the artist and some details about the artist (name and thumbnail).
93
93
  *
94
94
  * @example
95
- * fetchArtistLessons('drumeo', '10 Years', 'song', {'-published_on', '', 1, 10, ["difficulty,Intermediate"], [232168, 232824, 303375, 232194, 393125]})
95
+ * fetchArtistLessons('10 Years', 'drumeo', 'song', {'-published_on', '', 1, 10, ["difficulty,Intermediate"], [232168, 232824, 303375, 232194, 393125]})
96
96
  * .then(lessons => console.log(lessons))
97
97
  * .catch(error => console.error(error));
98
98
  */
99
99
  export async function fetchArtistLessons(
100
- brand: string,
101
100
  slug: string,
101
+ brand: string,
102
102
  contentType: string,
103
103
  {
104
104
  sort = '-published_on',
@@ -127,7 +127,7 @@ export async function fetchArtistLessons(
127
127
  progressIds !== undefined ? `&& railcontent_id in [${progressIds.join(',')}]` : ''
128
128
  const now = getSanityDate(new Date())
129
129
  const query = `{
130
- "entity":
130
+ "data":
131
131
  *[_type == 'artist' && slug.current == '${slug}']
132
132
  {'type': _type, name, 'thumbnail':thumbnail_url.asset->url,
133
133
  'lessons_count': count(*[${addType} brand == '${brand}' && references(^._id)]),
@@ -135,5 +135,5 @@ export async function fetchArtistLessons(
135
135
  [${start}...${end}]}
136
136
  |order(${sortOrder})
137
137
  }`
138
- return fetchSanity(query, true)
138
+ return fetchSanity(query, true, { processNeedAccess: false, processPageType: false })
139
139
  }
@@ -39,7 +39,7 @@ export async function fetchGenres(brand: string): Promise<Genre[]> {
39
39
  'thumbnail': thumbnail_url.asset->url,
40
40
  "lessons_count": count(*[${filter}])
41
41
  } |order(lower(name)) `
42
- return fetchSanity(query, true, { processNeedAccess: false, processPageType: false})
42
+ return fetchSanity(query, true, { processNeedAccess: false, processPageType: false })
43
43
  }
44
44
 
45
45
  /**
@@ -68,7 +68,7 @@ export async function fetchGenreBySlug(slug: string, brand?: string): Promise<Ge
68
68
  'thumbnail':thumbnail_url.asset->url,
69
69
  "lessonsCount": count(*[${filter}])
70
70
  }`
71
- return fetchSanity(query, true, { processNeedAccess: false, processPageType: false})
71
+ return fetchSanity(query, true, { processNeedAccess: false, processPageType: false })
72
72
  }
73
73
 
74
74
  export interface FetchGenreLessonsOptions {
@@ -81,13 +81,13 @@ export interface FetchGenreLessonsOptions {
81
81
  }
82
82
 
83
83
  export interface LessonsByGenreResponse {
84
- entity: Genre[]
84
+ data: Genre[]
85
85
  }
86
86
 
87
87
  /**
88
88
  * Fetch the genre's lessons.
89
- * @param {string} brand - The brand for which to fetch lessons.
90
89
  * @param {string} slug - The slug of the genre
90
+ * @param {string} brand - The brand for which to fetch lessons.
91
91
  * @param {Object} params - Parameters for sorting, searching, pagination and filtering.
92
92
  * @param {string} [params.sort="-published_on"] - The field to sort the lessons by.
93
93
  * @param {string} [params.searchTerm=""] - The search term to filter the lessons.
@@ -95,16 +95,16 @@ export interface LessonsByGenreResponse {
95
95
  * @param {number} [params.limit=10] - The number of items per page.
96
96
  * @param {Array<string>} [params.includedFields=[]] - Additional filters to apply to the query in the format of a key,value array. eg. ['difficulty,Intermediate', 'genre,rock'].
97
97
  * @param {Array<number>} [params.progressIds=[]] - The ids of the lessons that are in progress or completed
98
- * @returns {Promise<LessonsByGenreResponse>} - The lessons for the artist and some details about the artist (name and thumbnail).
98
+ * @returns {Promise<LessonsByGenreResponse|null>} - The lessons for the artist and some details about the artist (name and thumbnail).
99
99
  *
100
100
  * @example
101
- * fetchGenreLessons('drumeo', 'Blues', 'song', {'-published_on', '', 1, 10, ["difficulty,Intermediate"], [232168, 232824, 303375, 232194, 393125]})
101
+ * fetchGenreLessons('Blues', 'drumeo', 'song', {'-published_on', '', 1, 10, ["difficulty,Intermediate"], [232168, 232824, 303375, 232194, 393125]})
102
102
  * .then(lessons => console.log(lessons))
103
103
  * .catch(error => console.error(error));
104
104
  */
105
105
  export async function fetchGenreLessons(
106
- brand: string,
107
106
  slug: string,
107
+ brand: string,
108
108
  contentType: string,
109
109
  {
110
110
  sort = '-published_on',
@@ -114,7 +114,7 @@ export async function fetchGenreLessons(
114
114
  includedFields = [],
115
115
  progressIds = [],
116
116
  }: FetchGenreLessonsOptions = {}
117
- ): Promise<LessonsByGenreResponse> {
117
+ ): Promise<LessonsByGenreResponse | null> {
118
118
  const fieldsString = DEFAULT_FIELDS.join(',')
119
119
  const start = (page - 1) * limit
120
120
  const end = start + limit
@@ -127,12 +127,15 @@ export async function fetchGenreLessons(
127
127
  progressIds !== undefined ? `&& railcontent_id in [${progressIds.join(',')}]` : ''
128
128
  const now = getSanityDate(new Date())
129
129
  const query = `{
130
- "entity":
130
+ "data":
131
131
  *[_type == 'genre' && slug.current == '${slug}']
132
- {'type': _type, name, 'thumbnail':thumbnail_url.asset->url,
133
- 'lessons_count': count(*[${addType} brand == '${brand}' && references(^._id)]),
134
- 'lessons': *[${addType} brand == '${brand}' && references(^._id) && (status in ['published'] || (status == 'scheduled' && defined(published_on) && published_on >= '${now}')) ${searchFilter} ${includedFieldsFilter} ${progressFilter}]{${fieldsString}}
135
- [${start}...${end}]}
132
+ {
133
+ 'type': _type,
134
+ name,
135
+ 'thumbnail': thumbnail_url.asset->url,
136
+ 'lessons_count': count(*[${addType} brand == '${brand}' && references(^._id)]),
137
+ 'lessons': *[${addType} brand == '${brand}' && references(^._id) && (status in ['published'] || (status == 'scheduled' && defined(published_on) && published_on >= '${now}')) ${searchFilter} ${includedFieldsFilter} ${progressFilter}]{${fieldsString}} [${start}...${end}]
138
+ }
136
139
  |order(${sortOrder})
137
140
  }`
138
141
  return fetchSanity(query, true)
@@ -3,7 +3,7 @@
3
3
  */
4
4
  import { FilterBuilder } from '../../filterBuilder.js'
5
5
  import { filtersToGroq, getFieldsForContentType } from '../../contentTypeConfig.js'
6
- import { buildEntityAndTotalQuery, fetchSanity, getSortOrder } from '../sanity.js'
6
+ import { fetchSanity, getSanityDate, getSortOrder } from '../sanity.js'
7
7
  import { Lesson } from './content'
8
8
 
9
9
  export interface Instructor {
@@ -80,8 +80,7 @@ export interface FetchInstructorLessonsOptions {
80
80
  }
81
81
 
82
82
  export interface InstructorLessonsResponse {
83
- entity: Lesson[]
84
- total: number
83
+ data: Lesson[]
85
84
  }
86
85
 
87
86
  /**
@@ -96,9 +95,9 @@ export interface InstructorLessonsResponse {
96
95
  * @param {number} [options.limit=10] - The number of items per page.
97
96
  * @param {Array<string>} [options.includedFields=[]] - Additional filters to apply to the query in the format of a key,value array. eg. ['difficulty,Intermediate', 'genre,rock'].
98
97
  *
99
- * @returns {Promise<InstructorLessonsResponse>} - The lessons for the instructor or null if not found.
98
+ * @returns {Promise<InstructorLessonsResponse|null>} - The lessons for the instructor or null if not found.
100
99
  * @example
101
- * fetchInstructorLessons('instructor123')
100
+ * fetchInstructorLessons('instructor123', 'drumeo', { page: 2, limit: 10 })
102
101
  * .then(lessons => console.log(lessons))
103
102
  * .catch(error => console.error(error));
104
103
  */
@@ -112,20 +111,26 @@ export async function fetchInstructorLessons(
112
111
  limit = 20,
113
112
  includedFields = [],
114
113
  }: FetchInstructorLessonsOptions = {}
115
- ): Promise<InstructorLessonsResponse> {
114
+ ): Promise<InstructorLessonsResponse | null> {
116
115
  const fieldsString = getFieldsForContentType() as string
117
116
  const start = (page - 1) * limit
118
117
  const end = start + limit
119
118
  const searchFilter = searchTerm ? `&& title match "${searchTerm}*"` : ''
120
119
  const includedFieldsFilter = includedFields.length > 0 ? filtersToGroq(includedFields) : ''
121
- const filter = `brand == '${brand}' ${searchFilter} ${includedFieldsFilter} && references(*[_type=='instructor' && slug.current == '${slug}']._id)`
122
- const filterWithRestrictions = await new FilterBuilder(filter).buildFilter()
123
120
 
124
121
  sortOrder = getSortOrder(sortOrder, brand)
125
- const query = buildEntityAndTotalQuery(filterWithRestrictions, fieldsString, {
126
- sortOrder: sortOrder,
127
- start: start,
128
- end: end,
129
- })
130
- return fetchSanity(query, true)
122
+ const now = getSanityDate(new Date())
123
+ const query = `{
124
+ "data":
125
+ *[_type == 'instructor' && slug.current == '${slug}']
126
+ {
127
+ 'type': _type,
128
+ name,
129
+ 'thumbnail': thumbnail_url.asset->url,
130
+ 'lessons_count': count(*[brand == '${brand}' && references(^._id)]),
131
+ 'lessons': *[brand == '${brand}' && references(^._id) && (status in ['published'] || (status == 'scheduled' && defined(published_on) && published_on >= '${now}')) ${searchFilter} ${includedFieldsFilter}]{${fieldsString}} [${start}...${end}]
132
+ }
133
+ |order(${sortOrder})
134
+ }`
135
+ return fetchSanity(query, true, { processNeedAccess: false, processPageType: false })
131
136
  }
@@ -11,31 +11,52 @@ import {
11
11
  fetchUpcomingEvents,
12
12
  fetchScheduledReleases,
13
13
  fetchReturning,
14
- fetchLeaving, fetchScheduledAndNewReleases, fetchContentRows
14
+ fetchLeaving, fetchScheduledAndNewReleases, fetchContentRows, fetchOwnedContent
15
15
  } from './sanity.js'
16
16
  import {TabResponseType, Tabs, capitalizeFirstLetter} from '../contentMetaData.js'
17
17
  import {recommendations, rankCategories, rankItems} from "./recommendations";
18
18
  import {addContextToContent} from "./contentAggregator.js";
19
+ import {globalConfig} from "./config";
20
+ import {getUserData} from "./user/management";
21
+ import {filterTypes, ownedContentTypes} from "../contentTypeConfig";
19
22
 
20
23
 
21
24
  export async function getLessonContentRows (brand='drumeo', pageName = 'lessons') {
22
- let [recentContentIds, contentRows] = await Promise.all([
25
+ const [recentContentIds, rawContentRows, userData] = await Promise.all([
23
26
  fetchRecent(brand, pageName, { progress: 'recent', limit: 10 }),
24
- getContentRows(brand, pageName)
27
+ getContentRows(brand, pageName),
28
+ getUserData()
25
29
  ])
26
30
 
27
- contentRows = Array.isArray(contentRows) ? contentRows : [];
31
+ const contentRows = Array.isArray(rawContentRows) ? rawContentRows : []
32
+
33
+ // Only fetch owned content if user has no active membership
34
+ if (!userData?.has_active_membership) {
35
+ const type = ownedContentTypes[pageName] || []
36
+
37
+ const ownedContent = await fetchOwnedContent(brand, { type })
38
+ if (ownedContent?.entity && ownedContent.entity.length > 0) {
39
+ contentRows.unshift({
40
+ id: 'owned',
41
+ title: 'Owned ' + capitalizeFirstLetter(pageName),
42
+ items: ownedContent.entity
43
+ })
44
+ }
45
+ }
46
+
47
+ // Add recent content row
28
48
  contentRows.unshift({
29
49
  id: 'recent',
30
50
  title: 'Recent ' + capitalizeFirstLetter(pageName),
31
51
  items: recentContentIds || []
32
- });
52
+ })
33
53
 
34
54
  const results = await Promise.all(
35
- contentRows.map(async (row) => {
36
- return { id: row.id, title: row.title, items: row.items }
37
- })
55
+ contentRows.map(async (row) => {
56
+ return { id: row.id, title: row.title, items: row.items }
57
+ })
38
58
  )
59
+
39
60
  return results
40
61
  }
41
62
 
@@ -213,6 +234,7 @@ export async function getContentRows(brand, pageName, contentRowSlug = null, {
213
234
  }
214
235
  slugNameMap[category.slug] = category.name
215
236
  }
237
+
216
238
  const start = (page - 1) * limit
217
239
  const end = start + limit
218
240
  const sortedData = await rankCategories(brand, recData)
@@ -451,3 +473,59 @@ export async function getLegacyMethods(brand) {
451
473
  },
452
474
  ]
453
475
  }
476
+
477
+ /**
478
+ * Fetches content owned by the user (excluding membership content).
479
+ * Shows only content accessible through purchases/entitlements, not through membership.
480
+ *
481
+ * @param {string} brand - The brand for which to fetch owned content.
482
+ * @param {Object} [params={}] - Parameters for pagination and sorting.
483
+ * @param {Array<string>} [params.type=[]] - Content type(s) to filter (optional array).
484
+ * @param {number} [params.page=1] - The page number for pagination.
485
+ * @param {number} [params.limit=10] - The number of items per page.
486
+ * @param {string} [params.sort='-published_on'] - The field to sort the data by.
487
+ * @returns {Promise<Object>} - The fetched owned content with entity array and total count.
488
+ *
489
+ * @example
490
+ * // Fetch all owned content with default pagination
491
+ * getOwnedContent('drumeo')
492
+ * .then(content => console.log(content))
493
+ * .catch(error => console.error(error));
494
+ *
495
+ * @example
496
+ * // Fetch owned content with custom pagination and sorting
497
+ * getOwnedContent('drumeo', {
498
+ * page: 2,
499
+ * limit: 20,
500
+ * sort: '-published_on'
501
+ * })
502
+ * .then(content => console.log(content))
503
+ * .catch(error => console.error(error));
504
+ *
505
+ * @example
506
+ * // Fetch owned content filtered by types
507
+ * getOwnedContent('drumeo', {
508
+ * type: ['course', 'pack'],
509
+ * page: 1,
510
+ * limit: 10
511
+ * })
512
+ * .then(content => console.log(content))
513
+ * .catch(error => console.error(error));
514
+ */
515
+ export async function getOwnedContent(brand, {
516
+ type = [],
517
+ page = 1,
518
+ limit = 10,
519
+ sort = '-published_on',
520
+ } = {}) {
521
+ const data = await fetchOwnedContent(brand, { type, page, limit, sort });
522
+
523
+ if (!data) {
524
+ return {
525
+ entity: [],
526
+ total: 0
527
+ };
528
+ }
529
+
530
+ return data;
531
+ }
@@ -108,9 +108,9 @@ export async function addContextToContent(dataPromise, ...dataArgs) {
108
108
 
109
109
  const addContext = async (item) => ({
110
110
  ...item,
111
- ...(addProgressPercentage ? { progressPercentage: progressData?.[item.id]['progress'] } : {}),
112
- ...(addProgressStatus ? { progressStatus: progressData?.[item.id]['status'] } : {}),
113
- ...(addProgressTimestamp ? { progressTimestamp: progressData?.[item.id]['last_update'] } : {}),
111
+ ...(addProgressPercentage ? { progressPercentage: progressData?.[item.id]?.progress } : {}),
112
+ ...(addProgressStatus ? { progressStatus: progressData?.[item.id]?.status } : {}),
113
+ ...(addProgressTimestamp ? { progressTimestamp: progressData?.[item.id]?.last_update } : {}),
114
114
  ...(addIsLiked ? { isLiked: isLikedData?.[item.id] } : {}),
115
115
  ...(addLikeCount && ids.length === 1 ? { likeCount: await fetchLikeCount(item.id) } : {}),
116
116
  ...(addResumeTimeSeconds ? { resumeTime: resumeTimeData?.[item.id] } : {}),
@@ -144,7 +144,7 @@ export async function getNavigateToForPlaylists(data, { dataField = null } = {})
144
144
  const progress = progressOnItems[itemId]
145
145
  return progress && progress === 'completed'
146
146
  })
147
- let nextItem = accessibleItems[0] ?? null
147
+ let nextItem = accessibleItems[0] ?? playlist.items[0] ?? null
148
148
  if (!allItemsCompleted) {
149
149
  const lastItemProgress = progressOnItems[playlist.last_engaged_on]
150
150
  const index = accessibleItems.findIndex((i) => i.content_id === playlist.last_engaged_on)
@@ -50,7 +50,10 @@ export async function getNextLesson(data) {
50
50
  let nextLessonData = {}
51
51
 
52
52
  for (const content of data) {
53
- const children = content.children?.map((child) => child.id) ?? []
53
+ // Skip null/undefined entries (can happen when GROQ dereference doesn't match filter)
54
+ if (!content) continue
55
+
56
+ const children = content.children?.filter(Boolean).map((child) => child.id) ?? []
54
57
  //only calculate nextLesson if needed, based on content type
55
58
  if (!getNextLessonLessonParentTypes.includes(content.type)) {
56
59
  nextLessonData[content.id] = null
@@ -101,20 +104,30 @@ export async function getNavigateTo(data) {
101
104
  //TODO add parent hierarchy upwards as well
102
105
  // data structure is the same but instead of child{} we use parent{}
103
106
  for (const content of data) {
107
+ // Skip null/undefined entries (can happen when GROQ dereference doesn't match filter)
108
+ if (!content) continue
109
+
104
110
  //only calculate nextLesson if needed, based on content type
105
111
  if (!getNextLessonLessonParentTypes.includes(content.type) || !content.children) {
106
112
  navigateToData[content.id] = null
107
113
  } else {
114
+ // Filter out null/undefined children (can happen with permission filters)
115
+ const validChildren = content.children.filter(Boolean)
116
+ if (validChildren.length === 0) {
117
+ navigateToData[content.id] = null
118
+ continue
119
+ }
120
+
108
121
  const children = new Map()
109
122
  const childrenIds = []
110
- content.children.forEach((child) => {
123
+ validChildren.forEach((child) => {
111
124
  childrenIds.push(child.id)
112
125
  children.set(child.id, child)
113
126
  })
114
127
  // return first child (or grand child) if parent-content is complete or no progress
115
128
  const contentState = await getProgressState(content.id)
116
129
  if (contentState !== STATE_STARTED) {
117
- const firstChild = content.children[0]
130
+ const firstChild = validChildren[0]
118
131
  let lastInteractedChildNavToData = await getNavigateTo([firstChild])
119
132
  lastInteractedChildNavToData = lastInteractedChildNavToData[firstChild.id] ?? null
120
133
  navigateToData[content.id] = buildNavigateTo(firstChild, lastInteractedChildNavToData)
@@ -63,3 +63,22 @@ export async function updateForumCategory(
63
63
  const httpClient = new HttpClient(globalConfig.baseUrl)
64
64
  return httpClient.put<ForumCategory>(`${baseUrl}/v1/categories/${params.id}`, params)
65
65
  }
66
+
67
+ export interface DeleteForumCategoryParams {
68
+ id: number
69
+ brand: string
70
+ }
71
+
72
+ /**
73
+ * Deletes a forum category.
74
+ *
75
+ * @param {DeleteForumCategoryParams} params - The parameters for deleting the forum category.
76
+ * @returns {Promise<void>} - A promise that resolves when the category is deleted.
77
+ * @throws {HttpError} - If the request fails.
78
+ */
79
+ export async function deleteForumCategory(
80
+ params: DeleteForumCategoryParams
81
+ ): Promise<void> {
82
+ const httpClient = new HttpClient(globalConfig.baseUrl)
83
+ return httpClient.delete<void>(`${baseUrl}/v1/categories/${params.id}?brand=${params.brand}`)
84
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * @module PermissionsAdapter
3
+ *
4
+ * Abstract base class defining the contract for permissions operations.
5
+ * This abstraction allows swapping between different permission implementations
6
+ * (v1 and v2) without changing consumer code.
7
+ */
8
+
9
+ /**
10
+ * User permissions data structure
11
+ */
12
+ export interface UserPermissions {
13
+ /** Array of permission IDs the user has access to */
14
+ permissions: string[]
15
+ /** Whether the user is an admin */
16
+ isAdmin: boolean
17
+ /** Whether the user has basic membership */
18
+ isABasicMember: boolean
19
+ /** User's access level (v2 - for future use) */
20
+ access_level?: string
21
+ }
22
+
23
+ /**
24
+ * Options for generating permission filters
25
+ */
26
+ export interface PermissionFilterOptions {
27
+ /** GROQ prefix for nested queries (e.g., '^.' for parent, '@->' for children) */
28
+ prefix?: string
29
+ /**
30
+ * If true, show content that requires paid membership even if user doesn't have it.
31
+ * Used for upgrade prompts. V1: includes permissions [91, 92]. V2: shows content with membership_tier='plus'|'basic'|'free'
32
+ */
33
+ showMembershipRestrictedContent?: boolean
34
+ /**
35
+ * If true, show only content owned by user through purchases/entitlements, excluding membership content.
36
+ * V1: excludes permissions [91, 92]. V2: excludes content with membership_tier='plus'|'basic'
37
+ */
38
+ showOnlyOwnedContent?: boolean
39
+ }
40
+
41
+ /**
42
+ * Content item with permission requirements
43
+ */
44
+ export interface ContentItem {
45
+ /** Array of permission IDs required to access this content */
46
+ permission_id?: string[]
47
+ [key: string]: any
48
+ }
49
+
50
+ /**
51
+ * Abstract base class for permissions adapters.
52
+ * Subclasses must implement all methods to provide version-specific logic.
53
+ */
54
+ export abstract class PermissionsAdapter {
55
+ /**
56
+ * Fetch user permissions data.
57
+ *
58
+ * @returns The user's permissions data
59
+ * @abstract
60
+ */
61
+ abstract fetchUserPermissions(): Promise<UserPermissions>
62
+
63
+ /**
64
+ * Check if user needs access to specific content.
65
+ * Returns true if the user does NOT have access and needs to upgrade/purchase.
66
+ *
67
+ * @param content - The content item to check
68
+ * @param userPermissions - The user's permissions
69
+ * @returns True if user needs access, false if they already have it
70
+ * @abstract
71
+ */
72
+ abstract doesUserNeedAccess(
73
+ content: ContentItem,
74
+ userPermissions: UserPermissions
75
+ ): boolean
76
+
77
+ /**
78
+ * Generate GROQ filter string for permissions.
79
+ * Returns null if no filter should be applied (e.g., admin user).
80
+ *
81
+ * @param userPermissions - The user's permissions
82
+ * @param options - Options for filter generation
83
+ * @returns GROQ filter string or null
84
+ * @abstract
85
+ */
86
+ abstract generatePermissionsFilter(
87
+ userPermissions: UserPermissions,
88
+ options?: PermissionFilterOptions
89
+ ): string | null
90
+
91
+ /**
92
+ * Get user's permission IDs.
93
+ * Useful for cases where you need raw permission IDs.
94
+ *
95
+ * @param userPermissions - The user's permissions
96
+ * @returns Array of permission IDs
97
+ * @abstract
98
+ */
99
+ abstract getUserPermissionIds(userPermissions: UserPermissions): string[]
100
+
101
+ /**
102
+ * Check if user is an admin.
103
+ * Admins typically bypass all permission checks.
104
+ *
105
+ * @param userPermissions - The user's permissions
106
+ * @returns True if user is admin
107
+ */
108
+ isAdmin(userPermissions: UserPermissions): boolean {
109
+ return userPermissions?.isAdmin ?? false
110
+ }
111
+ }