musora-content-services 2.80.0 → 2.82.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 (220) hide show
  1. package/.claude/settings.local.json +14 -0
  2. package/.coderabbit.yaml +0 -0
  3. package/.editorconfig +0 -0
  4. package/.github/pull_request_template.md +0 -0
  5. package/.github/workflows/conventional-commits.yaml +0 -0
  6. package/.github/workflows/docs.js.yml +0 -0
  7. package/.github/workflows/node.js.yml +0 -0
  8. package/.prettierignore +0 -0
  9. package/.prettierrc +0 -0
  10. package/.yarnrc.yml +1 -0
  11. package/CHANGELOG.md +25 -0
  12. package/README.md +0 -0
  13. package/babel.config.cjs +0 -0
  14. package/docs/Content.html +0 -0
  15. package/docs/ContentOrganization.html +2 -2
  16. package/docs/Forums.html +2 -2
  17. package/docs/Gamification.html +2 -2
  18. package/docs/TestUser.html +2 -2
  19. package/docs/UserManagementSystem.html +2 -2
  20. package/docs/api_types.js.html +2 -2
  21. package/docs/config.js.html +5 -2
  22. package/docs/content-org_content-org.js.html +2 -2
  23. package/docs/content-org_guided-courses.ts.html +2 -2
  24. package/docs/content-org_learning-paths.ts.html +5 -2
  25. package/docs/content-org_playlists-types.js.html +2 -2
  26. package/docs/content-org_playlists.js.html +3 -2
  27. package/docs/content.js.html +88 -10
  28. package/docs/content_artist.ts.html +2 -3
  29. package/docs/content_content.ts.html +0 -0
  30. package/docs/content_genre.ts.html +2 -2
  31. package/docs/content_instructor.ts.html +2 -2
  32. package/docs/fonts/Montserrat/Montserrat-Bold.eot +0 -0
  33. package/docs/fonts/Montserrat/Montserrat-Bold.ttf +0 -0
  34. package/docs/fonts/Montserrat/Montserrat-Bold.woff +0 -0
  35. package/docs/fonts/Montserrat/Montserrat-Bold.woff2 +0 -0
  36. package/docs/fonts/Montserrat/Montserrat-Regular.eot +0 -0
  37. package/docs/fonts/Montserrat/Montserrat-Regular.ttf +0 -0
  38. package/docs/fonts/Montserrat/Montserrat-Regular.woff +0 -0
  39. package/docs/fonts/Montserrat/Montserrat-Regular.woff2 +0 -0
  40. package/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.eot +0 -0
  41. package/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.svg +0 -0
  42. package/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.ttf +0 -0
  43. package/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.woff +0 -0
  44. package/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.woff2 +0 -0
  45. package/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.eot +0 -0
  46. package/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.svg +0 -0
  47. package/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.ttf +0 -0
  48. package/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.woff +0 -0
  49. package/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.woff2 +0 -0
  50. package/docs/forums_categories.ts.html +2 -2
  51. package/docs/forums_discussions.js.html +0 -0
  52. package/docs/forums_forum.js.html +0 -0
  53. package/docs/forums_forums.ts.html +2 -2
  54. package/docs/forums_posts.ts.html +2 -2
  55. package/docs/forums_threads.ts.html +2 -2
  56. package/docs/gamification_awards.js.html +0 -0
  57. package/docs/gamification_awards.ts.html +2 -2
  58. package/docs/gamification_gamification.js.html +2 -2
  59. package/docs/gamification_types.js.html +0 -0
  60. package/docs/global.html +2 -2
  61. package/docs/index.html +2 -2
  62. package/docs/liveTesting.ts.html +2 -2
  63. package/docs/module-Accounts.html +14 -14
  64. package/docs/module-Artist.html +5 -5
  65. package/docs/module-Awards.html +2 -2
  66. package/docs/module-Categories.html +0 -0
  67. package/docs/module-Config.html +5 -4
  68. package/docs/module-Content-Services-V2.html +440 -9
  69. package/docs/module-ForumCategories.html +0 -0
  70. package/docs/module-ForumDiscussions.html +0 -0
  71. package/docs/module-Forums.html +2 -2
  72. package/docs/module-Genre.html +2 -2
  73. package/docs/module-GuidedCourses.html +2 -2
  74. package/docs/module-Instructor.html +2 -2
  75. package/docs/module-Interests.html +2 -2
  76. package/docs/module-LearningPaths.html +4 -4
  77. package/docs/module-Onboarding.html +2 -2
  78. package/docs/module-Payments.html +2 -2
  79. package/docs/module-Permissions.html +2 -2
  80. package/docs/module-Playlists.html +16 -16
  81. package/docs/module-ProgressRow.html +2 -2
  82. package/docs/module-Railcontent-Services.html +2 -2
  83. package/docs/module-Sanity-Services.html +1797 -56
  84. package/docs/module-Sessions.html +2 -2
  85. package/docs/module-Threads.html +0 -0
  86. package/docs/module-UserActivity.html +4 -4
  87. package/docs/module-UserChat.html +2 -2
  88. package/docs/module-UserManagement.html +2 -2
  89. package/docs/module-UserMemberships.html +2 -2
  90. package/docs/module-UserNotifications.html +2 -2
  91. package/docs/module-UserProfile.html +2 -2
  92. package/docs/progress-row_method-card.js.html +2 -2
  93. package/docs/railcontent.js.html +2 -2
  94. package/docs/sanity.js.html +131 -29
  95. package/docs/scripts/collapse.js +0 -0
  96. package/docs/scripts/commonNav.js +0 -0
  97. package/docs/scripts/linenumber.js +0 -0
  98. package/docs/scripts/nav.js +0 -0
  99. package/docs/scripts/polyfill.js +0 -0
  100. package/docs/scripts/prettify/Apache-License-2.0.txt +0 -0
  101. package/docs/scripts/prettify/lang-css.js +0 -0
  102. package/docs/scripts/prettify/prettify.js +0 -0
  103. package/docs/scripts/search.js +0 -0
  104. package/docs/styles/jsdoc.css +0 -0
  105. package/docs/styles/prettify.css +0 -0
  106. package/docs/userActivity.js.html +3 -2
  107. package/docs/user_account.ts.html +11 -4
  108. package/docs/user_chat.js.html +2 -2
  109. package/docs/user_interests.js.html +2 -2
  110. package/docs/user_management.js.html +2 -2
  111. package/docs/user_memberships.js.html +0 -0
  112. package/docs/user_memberships.ts.html +2 -2
  113. package/docs/user_notifications.js.html +2 -2
  114. package/docs/user_onboarding.ts.html +2 -2
  115. package/docs/user_payments.ts.html +2 -2
  116. package/docs/user_permissions.js.html +2 -2
  117. package/docs/user_profile.js.html +2 -2
  118. package/docs/user_sessions.js.html +2 -2
  119. package/docs/user_types.js.html +2 -2
  120. package/docs/user_user-management-system.js.html +2 -2
  121. package/jest.config.js +0 -0
  122. package/jsdoc.json +0 -0
  123. package/package.json +1 -1
  124. package/src/contentMetaData.js +0 -0
  125. package/src/contentTypeConfig.js +33 -1
  126. package/src/filterBuilder.js +22 -12
  127. package/src/index.d.ts +4 -0
  128. package/src/index.js +4 -0
  129. package/src/infrastructure/http/HttpClient.ts +0 -0
  130. package/src/infrastructure/http/executors/FetchRequestExecutor.ts +0 -0
  131. package/src/infrastructure/http/index.ts +0 -0
  132. package/src/infrastructure/http/interfaces/HeaderProvider.ts +0 -0
  133. package/src/infrastructure/http/interfaces/HttpError.ts +0 -0
  134. package/src/infrastructure/http/interfaces/NetworkError.ts +0 -0
  135. package/src/infrastructure/http/interfaces/RequestExecutor.ts +0 -0
  136. package/src/infrastructure/http/interfaces/RequestOptions.ts +0 -0
  137. package/src/infrastructure/http/providers/DefaultHeaderProvider.ts +0 -0
  138. package/src/lib/httpHelper.js +0 -0
  139. package/src/lib/lastUpdated.js +4 -4
  140. package/src/services/api/types.js +0 -0
  141. package/src/services/api/types.ts +0 -0
  142. package/src/services/config.js +3 -0
  143. package/src/services/content/artist.ts +0 -0
  144. package/src/services/content/content.ts +0 -0
  145. package/src/services/content/genre.ts +0 -0
  146. package/src/services/content/instructor.ts +0 -0
  147. package/src/services/content-org/content-org.js +0 -0
  148. package/src/services/content-org/guided-courses.ts +0 -0
  149. package/src/services/content-org/learning-paths.ts +3 -0
  150. package/src/services/content-org/playlists-types.js +0 -0
  151. package/src/services/content-org/playlists.js +0 -0
  152. package/src/services/content.js +86 -8
  153. package/src/services/contentAggregator.js +4 -4
  154. package/src/services/contentLikes.js +0 -0
  155. package/src/services/contentProgress.js +16 -3
  156. package/src/services/dataContext.js +0 -0
  157. package/src/services/dateUtils.js +0 -0
  158. package/src/services/eventsAPI.js +0 -0
  159. package/src/services/forums/categories.ts +0 -0
  160. package/src/services/forums/forums.ts +0 -0
  161. package/src/services/forums/posts.ts +0 -0
  162. package/src/services/forums/threads.ts +0 -0
  163. package/src/services/forums/types.ts +0 -0
  164. package/src/services/gamification/awards.ts +0 -0
  165. package/src/services/gamification/gamification.js +0 -0
  166. package/src/services/imageSRCBuilder.js +0 -0
  167. package/src/services/imageSRCVerify.js +0 -0
  168. package/src/services/liveTesting.ts +0 -0
  169. package/src/services/permissions/PermissionsAdapter.ts +111 -0
  170. package/src/services/permissions/PermissionsAdapterFactory.ts +71 -0
  171. package/src/services/permissions/PermissionsV1Adapter.ts +232 -0
  172. package/src/services/permissions/PermissionsV2Adapter.ts +226 -0
  173. package/src/services/permissions/README.md +139 -0
  174. package/src/services/permissions/index.ts +65 -0
  175. package/src/services/progress-row/method-card.js +0 -0
  176. package/src/services/railcontent.js +0 -0
  177. package/src/services/recommendations.js +0 -0
  178. package/src/services/sanity.js +103 -40
  179. package/src/services/types.js +1 -0
  180. package/src/services/user/account.ts +9 -2
  181. package/src/services/user/chat.js +0 -0
  182. package/src/services/user/interests.js +0 -0
  183. package/src/services/user/management.js +0 -0
  184. package/src/services/user/memberships.ts +0 -0
  185. package/src/services/user/notifications.js +0 -0
  186. package/src/services/user/onboarding.ts +0 -0
  187. package/src/services/user/payments.ts +0 -0
  188. package/src/services/user/permissions.js +1 -1
  189. package/src/services/user/profile.js +0 -0
  190. package/src/services/user/sessions.js +0 -0
  191. package/src/services/user/types.d.ts +133 -0
  192. package/src/services/user/types.js +0 -0
  193. package/src/services/user/user-management-system.js +0 -0
  194. package/src/services/userActivity.js +1 -0
  195. package/test/HttpClient.test.js +0 -0
  196. package/test/content.test.js +5 -0
  197. package/test/contentLikes.test.js +0 -0
  198. package/test/contentProgress.test.js +0 -0
  199. package/test/dataContext.test.js +0 -0
  200. package/test/forum.test.js +1 -1
  201. package/test/imageSRCBuilder.test.js +0 -0
  202. package/test/imageSRCVerify.test.js +0 -0
  203. package/test/initializeTests.js +5 -3
  204. package/test/learningPaths.test.js +0 -0
  205. package/test/lib/lastUpdated.test.js +0 -0
  206. package/test/live/contentProgressLive.test.js +0 -0
  207. package/test/live/railcontentLive.test.js +0 -0
  208. package/test/localStorageMock.js +0 -0
  209. package/test/log.js +0 -0
  210. package/test/mockData/mockData_fetchByRailContentIds_one_content.json +0 -0
  211. package/test/mockData/mockData_progress_content.json +0 -0
  212. package/test/mockData/mockData_sanity_progress_content.json +0 -0
  213. package/test/mockData/mockData_user_practices.json +0 -0
  214. package/test/notifications.test.js +0 -0
  215. package/test/progressRows.test.js +0 -0
  216. package/test/sanityQueryService.test.js +0 -0
  217. package/test/streakMessage.test.js +0 -0
  218. package/test/user/permissions.test.js +0 -0
  219. package/test/userActivity.test.js +0 -0
  220. package/tools/generate-index.cjs +5 -0
@@ -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
  }
File without changes
File without changes
@@ -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
  }
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
@@ -218,9 +218,12 @@ export async function fetchLearningPathLessons(
218
218
  ) {
219
219
  // Daily sessions first lessons are the active learning path and the next lessons are not
220
220
  // load next lessons from next learning path
221
+ // TODO: update item status to locked when the current learning path is not complete
221
222
  nextLPLessons = await getLearningPathLessonsByIds(nextContentIds, nextLearningPathId)
222
223
  }
223
224
 
225
+
226
+
224
227
  return {
225
228
  ...learningPath,
226
229
  is_active_learning_path: isActiveLearningPath,
File without changes
File without changes
@@ -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)
File without changes
@@ -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)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
@@ -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
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * @module PermissionsAdapterFactory
3
+ *
4
+ * Factory for creating the appropriate permissions adapter based on configuration.
5
+ * Provides a single point of control for switching between v1 and v2 implementations.
6
+ */
7
+
8
+ import { PermissionsAdapter } from './PermissionsAdapter'
9
+ import { PermissionsV1Adapter } from './PermissionsV1Adapter'
10
+ import { PermissionsV2Adapter } from './PermissionsV2Adapter'
11
+ import { globalConfig } from '../config.js'
12
+
13
+ /**
14
+ * Valid permissions version types
15
+ */
16
+ export type PermissionsVersion = 'v1' | 'v2'
17
+
18
+ /**
19
+ * Singleton instance of the permissions adapter.
20
+ * Created once and reused throughout the application.
21
+ */
22
+ let adapterInstance: PermissionsAdapter | null = null
23
+
24
+ /**
25
+ * Get the appropriate permissions adapter based on configuration.
26
+ *
27
+ * This is a singleton - the same adapter instance is returned on every call.
28
+ * To switch versions, change globalConfig.permissionsVersion via initializeService().
29
+ *
30
+ * @returns The permissions adapter instance
31
+ * @throws Error if an invalid permissions version is configured
32
+ *
33
+ * @example
34
+ * import { getPermissionsAdapter } from './permissions/PermissionsAdapterFactory.js'
35
+ *
36
+ * const adapter = getPermissionsAdapter()
37
+ * const permissions = await adapter.fetchUserPermissions()
38
+ */
39
+ export function getPermissionsAdapter(): PermissionsAdapter {
40
+ if (adapterInstance) {
41
+ return adapterInstance
42
+ }
43
+
44
+ const version = (globalConfig.permissionsVersion || 'v1') as PermissionsVersion
45
+
46
+ switch (version.toLowerCase()) {
47
+ case 'v1':
48
+ adapterInstance = new PermissionsV1Adapter()
49
+ break
50
+
51
+ case 'v2':
52
+ adapterInstance = new PermissionsV2Adapter()
53
+ break
54
+
55
+ default:
56
+ throw new Error(
57
+ `Invalid permissionsVersion: ${version}. Must be 'v1' or 'v2'.`
58
+ )
59
+ }
60
+
61
+ return adapterInstance
62
+ }
63
+
64
+ /**
65
+ * Get the current permissions version being used.
66
+ *
67
+ * @returns The permissions version ('v1' or 'v2')
68
+ */
69
+ export function getPermissionsVersion(): PermissionsVersion {
70
+ return (globalConfig.permissionsVersion || 'v1') as PermissionsVersion
71
+ }