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
@@ -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
+ }
@@ -0,0 +1,232 @@
1
+ /**
2
+ * @module PermissionsV1Adapter
3
+ *
4
+ * Permissions adapter for the current (v1) permissions system.
5
+ * Implements the existing permission logic using permission IDs.
6
+ */
7
+
8
+ import {
9
+ PermissionsAdapter,
10
+ UserPermissions,
11
+ ContentItem,
12
+ PermissionFilterOptions,
13
+ } from './PermissionsAdapter'
14
+ import {
15
+ fetchUserPermissions as fetchUserPermissionsV1,
16
+ } from '../user/permissions.js'
17
+ import { plusMembershipPermissions, membershipPermissions } from '../../contentTypeConfig.js'
18
+ import { arrayToRawRepresentation } from '../../filterBuilder.js'
19
+
20
+ /**
21
+ * V1 Permissions Adapter implementing the current permissions system.
22
+ *
23
+ * Logic:
24
+ * - Content access based on permission IDs
25
+ * - Admins bypass all checks
26
+ * - Content with no permissions is accessible to all
27
+ * - User needs at least ONE matching permission to access content
28
+ * - If showMembershipRestrictedContent: also show content that requires membership
29
+ */
30
+ export class PermissionsV1Adapter extends PermissionsAdapter {
31
+ /**
32
+ * Fetch user permissions data from v1 API.
33
+ *
34
+ * @returns The user's permissions data
35
+ */
36
+ async fetchUserPermissions(): Promise<UserPermissions> {
37
+ return await fetchUserPermissionsV1()
38
+ }
39
+
40
+ /**
41
+ * Check if user needs access to specific content.
42
+ *
43
+ * V1 Logic:
44
+ * - Admins always have access (return false)
45
+ * - Content with no permissions is accessible (return false)
46
+ * - User must have at least ONE matching permission (return false if found)
47
+ * - Otherwise user needs access (return true)
48
+ *
49
+ * @param content - The content item with permission_id array
50
+ * @param userPermissions - The user's permissions
51
+ * @returns True if user needs access, false if they have it
52
+ */
53
+ doesUserNeedAccess(content: ContentItem, userPermissions: UserPermissions): boolean {
54
+ // Admins always have access
55
+ if (this.isAdmin(userPermissions)) {
56
+ return false
57
+ }
58
+
59
+ // Get content's required permissions
60
+ const contentPermissions = new Set(content?.permission_id ?? [])
61
+
62
+ // Content with no permissions is accessible to all
63
+ if (contentPermissions.size === 0) {
64
+ return false
65
+ }
66
+
67
+ // Convert user permissions to Set for fast lookup
68
+ const userPermissionSet = new Set(userPermissions?.permissions ?? [])
69
+
70
+ // Check if user has ANY of the required permissions
71
+ for (const permission of contentPermissions) {
72
+ if (userPermissionSet.has(permission)) {
73
+ return false // User has access
74
+ }
75
+ }
76
+
77
+ // User doesn't have any required permissions
78
+ return true
79
+ }
80
+
81
+ /**
82
+ * Generate GROQ filter string for permissions.
83
+ *
84
+ * V1 Logic:
85
+ * - Admins bypass filter (return null)
86
+ * - Filter: content has no permission OR references user's permissions
87
+ * - If showMembershipRestrictedContent: also show content that requires membership
88
+ * - If showOnlyOwnedContent: exclude membership content (permissions 91, 92), show only purchased/owned content
89
+ *
90
+ * @param userPermissions - The user's permissions
91
+ * @param options - Options for filter generation
92
+ * @returns GROQ filter string or null for admin
93
+ */
94
+ generatePermissionsFilter(
95
+ userPermissions: UserPermissions,
96
+ options: PermissionFilterOptions = {}
97
+ ): string | null {
98
+ const {
99
+ prefix = '',
100
+ showMembershipRestrictedContent = false,
101
+ showOnlyOwnedContent = false,
102
+ } = options
103
+
104
+ // Admins bypass permission filter
105
+ if (this.isAdmin(userPermissions)) {
106
+ return null
107
+ }
108
+
109
+ const userPermissionIds = this.getUserPermissionIds(userPermissions)
110
+ const isDereferencedContext = prefix === '@->'
111
+
112
+ if (showOnlyOwnedContent) {
113
+ return this.buildOwnedContentFilter(userPermissionIds, prefix, isDereferencedContext)
114
+ }
115
+
116
+ return this.buildStandardPermissionFilter(
117
+ userPermissionIds,
118
+ prefix,
119
+ isDereferencedContext,
120
+ showMembershipRestrictedContent
121
+ )
122
+ }
123
+
124
+ /**
125
+ * Build filter for owned content only (excluding membership content).
126
+ *
127
+ * @param userPermissionIds - User's permission IDs
128
+ * @param prefix - GROQ prefix for nested queries
129
+ * @param isDereferencedContext - Whether we're in a dereferenced context
130
+ * @returns GROQ filter string for owned content
131
+ */
132
+ private buildOwnedContentFilter(
133
+ userPermissionIds: string[],
134
+ prefix: string,
135
+ isDereferencedContext: boolean
136
+ ): string {
137
+ // Filter out membership permissions to get only owned permissions
138
+ const ownedPermissions = userPermissionIds.filter(
139
+ permId => !membershipPermissions.includes(parseInt(permId))
140
+ )
141
+
142
+ if (ownedPermissions.length === 0) {
143
+ // User has no owned permissions (only membership or none at all)
144
+ // Return filter that matches nothing - user can't see any owned content
145
+ return `${prefix}_id == null`
146
+ }
147
+
148
+ // Content must have at least one owned permission AND no membership permissions
149
+ if (isDereferencedContext) {
150
+ // In dereferenced context, check arrays directly
151
+ const hasOwnedPermission = `count((${prefix}permission[]._ref)[@ in *[_type == 'permission' && railcontent_id in ${arrayToRawRepresentation(ownedPermissions)}]._id]) > 0`
152
+ const hasMembershipPermission = `count((${prefix}permission[]._ref)[@ in *[_type == 'permission' && railcontent_id in ${arrayToRawRepresentation(membershipPermissions)}]._id]) > 0`
153
+ return `(${hasOwnedPermission} && !${hasMembershipPermission})`
154
+ }
155
+
156
+ // Non-dereferenced: use references() function
157
+ const hasOwnedPermissions = `references(*[_type == 'permission' && railcontent_id in ${arrayToRawRepresentation(ownedPermissions)}]._id)`
158
+ const hasMembershipPermissions = `references(*[_type == 'permission' && railcontent_id in ${arrayToRawRepresentation(membershipPermissions)}]._id)`
159
+ return `(${hasOwnedPermissions} && !${hasMembershipPermissions})`
160
+ }
161
+
162
+ /**
163
+ * Build standard permission filter (content with no permissions OR user has matching permissions).
164
+ *
165
+ * @param userPermissionIds - User's permission IDs
166
+ * @param prefix - GROQ prefix for nested queries
167
+ * @param isDereferencedContext - Whether we're in a dereferenced context
168
+ * @param showMembershipRestrictedContent - Whether to show membership-restricted content
169
+ * @returns GROQ filter string for standard permissions
170
+ */
171
+ private buildStandardPermissionFilter(
172
+ userPermissionIds: string[],
173
+ prefix: string,
174
+ isDereferencedContext: boolean,
175
+ showMembershipRestrictedContent: boolean
176
+ ): string {
177
+ const clauses: string[] = []
178
+
179
+ // Content with no permissions is accessible to all
180
+ clauses.push(`!defined(${prefix}permission)`)
181
+
182
+ // User has matching permissions
183
+ if (userPermissionIds.length > 0) {
184
+ clauses.push(this.buildPermissionCheck(userPermissionIds, prefix, isDereferencedContext))
185
+ }
186
+
187
+ // Include membership-restricted content
188
+ if (showMembershipRestrictedContent) {
189
+ clauses.push(
190
+ this.buildPermissionCheck(
191
+ membershipPermissions.map(String),
192
+ prefix,
193
+ isDereferencedContext
194
+ )
195
+ )
196
+ }
197
+
198
+ return `(${clauses.join(' || ')})`
199
+ }
200
+
201
+ /**
202
+ * Build GROQ permission check for given permissions.
203
+ * Handles both dereferenced and standard contexts.
204
+ *
205
+ * @param permissions - Permission IDs to check
206
+ * @param prefix - GROQ prefix for nested queries
207
+ * @param isDereferencedContext - Whether we're in a dereferenced context
208
+ * @returns GROQ filter string for permission check
209
+ */
210
+ private buildPermissionCheck(
211
+ permissions: string[],
212
+ prefix: string,
213
+ isDereferencedContext: boolean
214
+ ): string {
215
+ if (isDereferencedContext) {
216
+ // In dereferenced context, check the permission array directly
217
+ return `count((${prefix}permission[]._ref)[@ in *[_type == 'permission' && railcontent_id in ${arrayToRawRepresentation(permissions)}]._id]) > 0`
218
+ }
219
+ // In standard context, use references() function
220
+ return `references(*[_type == 'permission' && railcontent_id in ${arrayToRawRepresentation(permissions)}]._id)`
221
+ }
222
+
223
+ /**
224
+ * Get user's permission IDs.
225
+ *
226
+ * @param userPermissions - The user's permissions
227
+ * @returns Array of permission IDs
228
+ */
229
+ getUserPermissionIds(userPermissions: UserPermissions): string[] {
230
+ return userPermissions?.permissions ?? []
231
+ }
232
+ }
@@ -0,0 +1,226 @@
1
+ /**
2
+ * @module PermissionsV2Adapter
3
+ *
4
+ * Permissions adapter for the new (v2) permissions system.
5
+ * This is a placeholder implementation to be completed when v2 is ready.
6
+ *
7
+ * Key differences from v1:
8
+ * - New permission structure (to be defined)
9
+ */
10
+
11
+ import {
12
+ PermissionsAdapter,
13
+ UserPermissions,
14
+ ContentItem,
15
+ PermissionFilterOptions,
16
+ } from './PermissionsAdapter'
17
+ import {
18
+ fetchUserPermissions as fetchUserPermissionsV2,
19
+ } from '../user/permissions.js'
20
+ import {arrayToRawRepresentation, arrayToStringRepresentation} from '../../filterBuilder.js'
21
+ import {basicMembershipTier, plusMembershipPermissions, plusMembershipTier} from "../../contentTypeConfig";
22
+
23
+ /**
24
+ * V2 Permissions Adapter for the new permissions system.
25
+ *
26
+ * Expected changes:
27
+ * - Different permission data structure
28
+ * - Potentially different GROQ filter logic
29
+ */
30
+ export class PermissionsV2Adapter extends PermissionsAdapter {
31
+ /**
32
+ * Fetch user permissions data from API.
33
+ *
34
+ * @returns The user's permissions data
35
+ */
36
+ async fetchUserPermissions(): Promise<UserPermissions> {
37
+ return await fetchUserPermissionsV2()
38
+ }
39
+
40
+ /**
41
+ * Check if user needs access to specific content.
42
+ *
43
+ * @param content - The content item
44
+ * @param userPermissions - The user's permissions
45
+ * @returns True if user needs access, false if they have it
46
+ */
47
+ doesUserNeedAccess(content: ContentItem, userPermissions: UserPermissions): boolean {
48
+ // Admins always have access
49
+ if (this.isAdmin(userPermissions)) {
50
+ return false
51
+ }
52
+
53
+ // Get content's required permissions
54
+ const oldPermissions = content?.permission_id ?? []
55
+ const newPermissions = content?.permission_v2 ?? []
56
+ const contentPermissions = new Set([...oldPermissions, ...newPermissions])
57
+
58
+ // Content with no permissions is accessible to all
59
+ if (contentPermissions.size === 0) {
60
+ return false
61
+ }
62
+
63
+ // Convert user permissions to Set for fast lookup
64
+ const userPermissionSet = new Set(userPermissions?.permissions ?? [])
65
+
66
+ // Check if user has ANY of the required permissions
67
+ for (const permission of contentPermissions) {
68
+ if (userPermissionSet.has(permission)) {
69
+ return false // User has access
70
+ }
71
+ }
72
+
73
+ // User doesn't have any required permissions
74
+ return true
75
+ }
76
+
77
+ /**
78
+ * Generate GROQ filter string for permissions.
79
+ *
80
+ * TODO: Implement v2 filter generation
81
+ *
82
+ * V2 Logic:
83
+ * - When showMembershipRestrictedContent is true:
84
+ * * Show content restricted by Plus Membership
85
+ * * This supports the membership upgrade modal
86
+ * - When showOnlyOwnedContent is true:
87
+ * * Shows only purchased/owned content
88
+ *
89
+ * @param userPermissions - The user's permissions
90
+ * @param options - Options for filter generation
91
+ * @returns GROQ filter string or null
92
+ */
93
+ generatePermissionsFilter(
94
+ userPermissions: UserPermissions,
95
+ options: PermissionFilterOptions = {}
96
+ ): string | null {
97
+ const {
98
+ prefix = '',
99
+ showMembershipRestrictedContent = false,
100
+ showOnlyOwnedContent = false,
101
+ } = options
102
+
103
+ // Admins bypass permission filter
104
+ if (this.isAdmin(userPermissions)) {
105
+ return null
106
+ }
107
+
108
+ // If showOnlyOwnedContent, show only purchased/owned content
109
+ if (showOnlyOwnedContent) {
110
+ return this.buildOwnedContentFilter(userPermissions)
111
+ }
112
+
113
+ let userPermissionIds = this.getUserPermissionIds(userPermissions)
114
+ const isDereferencedContext = prefix === '@->'
115
+
116
+ // If showing membership restricted content
117
+ if (showMembershipRestrictedContent) {
118
+ return ` ${prefix}membership_tier in ${arrayToStringRepresentation([plusMembershipTier, basicMembershipTier])} `
119
+ }
120
+
121
+ const filter = this.buildStandardPermissionFilter(
122
+ userPermissionIds,
123
+ prefix,
124
+ isDereferencedContext
125
+ )
126
+
127
+ return filter
128
+ }
129
+
130
+ /**
131
+ * Build filter for owned content only (a-la-carte purchases).
132
+ *
133
+ * In V2, owned content is identified by permission IDs >= 100000000.
134
+ * These permissions represent direct purchases of individual content items.
135
+ *
136
+ * Logic:
137
+ * 1. Extract user's permission IDs
138
+ * 2. Filter for IDs >= 100000000 (owned content permissions)
139
+ * 3. Convert permission IDs to content IDs: content_id = permission_id - 100000000
140
+ * 4. Filter content to show only items matching these content IDs
141
+ *
142
+ * @param userPermissions - The user's permissions
143
+ * @returns GROQ filter string for owned content
144
+ */
145
+ private buildOwnedContentFilter(userPermissions: UserPermissions): string {
146
+ const minContentPermissionId = 100000000
147
+ const userPermissionIds = this.getUserPermissionIds(userPermissions)
148
+
149
+ // Convert permission IDs to content IDs
150
+ // Permission ID format: 100000000 + railcontent_id
151
+ const ownedContentIds = userPermissionIds
152
+ .map((permissionId) => parseInt(permissionId) - minContentPermissionId)
153
+ .filter((contentId) => contentId > 0)
154
+
155
+ if (ownedContentIds.length === 0) {
156
+ // User has no owned content permissions
157
+ // Return filter that matches nothing
158
+ return `railcontent_id == null`
159
+ }
160
+
161
+ // Content must be in owned content IDs
162
+ const ownerContentFilter = `railcontent_id in ${arrayToRawRepresentation(ownedContentIds)}`
163
+ return ` ${ownerContentFilter} `
164
+ }
165
+
166
+ /**
167
+ * Build standard permission filter (content with no permissions OR user has matching permissions).
168
+ *
169
+ * @param userPermissionIds - User's permission IDs
170
+ * @param prefix - GROQ prefix for nested queries
171
+ * @param isDereferencedContext - Whether we're in a dereferenced context
172
+ * @returns GROQ filter string for standard permissions
173
+ */
174
+ private buildStandardPermissionFilter(
175
+ userPermissionIds: string[],
176
+ prefix: string,
177
+ isDereferencedContext: boolean
178
+ ): string {
179
+ const clauses: string[] = []
180
+
181
+ // Content with no permissions is accessible to all
182
+ // A content has "no permissions" if BOTH permission and permission_v2 are empty/undefined
183
+ clauses.push(`((!defined(${prefix}permission) || count(${prefix}permission) == 0) && (!defined(${prefix}permission_v2) || count(${prefix}permission_v2) == 0))`)
184
+
185
+ // User has matching permissions
186
+ if (userPermissionIds.length > 0) {
187
+ clauses.push(this.buildPermissionCheck(userPermissionIds, prefix, isDereferencedContext))
188
+ }
189
+
190
+ return `(${clauses.join(' || ')})`
191
+ }
192
+
193
+ /**
194
+ * Build GROQ permission check for given permissions.
195
+ * Handles both dereferenced and standard contexts.
196
+ *
197
+ * @param permissions - Permission IDs to check
198
+ * @param prefix - GROQ prefix for nested queries
199
+ * @param isDereferencedContext - Whether we're in a dereferenced context
200
+ * @returns GROQ filter string for permission check
201
+ */
202
+ private buildPermissionCheck(
203
+ permissions: string[],
204
+ prefix: string,
205
+ isDereferencedContext: boolean
206
+ ): string {
207
+ if (isDereferencedContext) {
208
+ // In dereferenced context, check the permission array directly
209
+ return `((count((${prefix}permission[]._ref)[@ in *[_type == 'permission' && railcontent_id in ${arrayToRawRepresentation(permissions)}]._id]) > 0)
210
+ || count(array::intersects(${prefix}permission_v2, ${arrayToRawRepresentation(permissions)})) > 0)`
211
+ }
212
+ // In standard context, use references() function
213
+ return `(references(*[_type == 'permission' && railcontent_id in ${arrayToRawRepresentation(permissions)}]._id) ||
214
+ count(array::intersects(permission_v2, ${arrayToRawRepresentation(permissions)})) > 0)`
215
+ }
216
+
217
+ /**
218
+ * Get user's permission IDs.
219
+ *
220
+ * @param userPermissions - The user's permissions
221
+ * @returns Array of permission IDs
222
+ */
223
+ getUserPermissionIds(userPermissions: UserPermissions): string[] {
224
+ return userPermissions?.permissions ?? []
225
+ }
226
+ }
@@ -0,0 +1,139 @@
1
+ # Permissions Module
2
+
3
+ **Internal abstraction layer for permissions in Musora Content Services.**
4
+
5
+ ## Purpose
6
+
7
+ This module is **internal to MCS only** and provides a flexible contract between content services and permissions implementations, enabling seamless switching between v1 and v2.
8
+
9
+ **Note:** This module is not exported from the package. External consumers should use `fetchUserPermissions()` if they need user permissions data.
10
+
11
+ ## Files
12
+
13
+ - **`index.ts`** - Main exports (TypeScript)
14
+ - **`PermissionsAdapter.ts`** - Abstract base class defining the contract (TypeScript)
15
+ - **`PermissionsV1Adapter.ts`** - V1 implementation (current system, TypeScript)
16
+ - **`PermissionsV2Adapter.ts`** - V2 implementation (placeholder, TypeScript)
17
+ - **`PermissionsAdapterFactory.ts`** - Factory for getting appropriate adapter (TypeScript)
18
+
19
+ ## Usage (Internal MCS only)
20
+
21
+ ```typescript
22
+ import { getPermissionsAdapter } from './permissions/index.js'
23
+
24
+ // Get adapter (automatically selects v1 or v2 based on config)
25
+ const adapter = getPermissionsAdapter()
26
+
27
+ // Fetch user permissions
28
+ const permissions = await adapter.fetchUserPermissions()
29
+
30
+ // Check if user is admin
31
+ if (adapter.isAdmin(permissions)) {
32
+ // Admin logic
33
+ }
34
+
35
+ // Check if user needs access to content
36
+ const needsAccess = adapter.doesUserNeedAccess(content, permissions)
37
+
38
+ // Generate GROQ filter for permissions
39
+ const filter = adapter.generatePermissionsFilter(permissions, {
40
+ prefix: '',
41
+ showMembershipRestrictedContent: false, // Set true to show membership-restricted content for upgrades
42
+ })
43
+ ```
44
+
45
+ ## Configuration
46
+
47
+ Set `permissionsVersion` in `initializeService()`:
48
+
49
+ ```javascript
50
+ import { initializeService } from 'musora-content-services'
51
+
52
+ initializeService({
53
+ // ... other config
54
+ permissionsVersion: 'v1', // 'v1' (default) or 'v2'
55
+ })
56
+ ```
57
+
58
+ ## Documentation
59
+
60
+ For full details, see the inline documentation in each TypeScript file.
61
+
62
+ ## Architecture
63
+
64
+ ```
65
+ Internal MCS Code
66
+
67
+ PermissionsAdapterFactory (getPermissionsAdapter)
68
+
69
+ PermissionsV1Adapter OR PermissionsV2Adapter
70
+
71
+ V1/V2 Implementation
72
+ ```
73
+
74
+
75
+ ## Contract
76
+
77
+ All adapters must implement:
78
+
79
+ - `fetchUserPermissions()` - Fetch user's permissions data
80
+ - `doesUserNeedAccess(content, userPermissions)` - Check if user needs access
81
+ - `generatePermissionsFilter(userPermissions, options)` - Generate GROQ filter
82
+ - `getUserPermissionIds(userPermissions)` - Get permission IDs
83
+ - `isAdmin(userPermissions)` - Check if user is admin
84
+
85
+ ## V1 Implementation
86
+
87
+ Current permissions system:
88
+ - Permission-based access using permission IDs
89
+ - Basic members automatically get access to songs content (permission 92 added to their permissions)
90
+ - Admins bypass all checks
91
+ - Content with no permissions is accessible to all
92
+
93
+ ### Show Membership-Restricted Content
94
+
95
+ V1 adapter supports showing membership-restricted content for upgrade prompts:
96
+
97
+ ```typescript
98
+ const filter = adapter.generatePermissionsFilter(permissions, {
99
+ showMembershipRestrictedContent: true,
100
+ })
101
+ ```
102
+
103
+ This shows content requiring paid membership (permissions 91 for Basic, 92 for Plus) even if user doesn't have it. Content requiring other permissions (packs, individual purchases) is still filtered.
104
+
105
+ **Use case:** Show non-members what membership-tier content is available to encourage upgrades.
106
+
107
+ ## V2 Implementation
108
+
109
+ New permissions system (placeholder):
110
+ - Different permission structure (TBD)
111
+
112
+ ## Testing
113
+
114
+ Mock the adapter for tests:
115
+
116
+ ```typescript
117
+ import { PermissionsAdapter, UserPermissions } from './permissions/PermissionsAdapter.js'
118
+
119
+ class MockAdapter extends PermissionsAdapter {
120
+ async fetchUserPermissions(): Promise<UserPermissions> {
121
+ return { permissions: ['78', '91'], isAdmin: false, isABasicMember: true }
122
+ }
123
+
124
+ doesUserNeedAccess(content: any, userPermissions: UserPermissions): boolean {
125
+ return false // User has access
126
+ }
127
+
128
+ // ... implement other methods
129
+ }
130
+ ```
131
+
132
+ ## Contributing
133
+
134
+ When implementing V2:
135
+
136
+ 1. Update `PermissionsV2Adapter.ts` with v2 logic
137
+ 2. Test with `permissionsVersion: 'v2'` in `initializeService()`
138
+ 3. Update documentation
139
+ 4. Coordinate with frontend team for data structure changes