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.
- package/CHANGELOG.md +24 -0
- package/docs/ContentOrganization.html +2 -2
- package/docs/Forums.html +2 -2
- package/docs/Gamification.html +2 -2
- package/docs/TestUser.html +2 -2
- package/docs/UserManagementSystem.html +2 -2
- package/docs/api_types.js.html +2 -2
- package/docs/config.js.html +5 -2
- package/docs/content-org_content-org.js.html +2 -2
- package/docs/content-org_guided-courses.ts.html +2 -2
- package/docs/content-org_learning-paths.ts.html +2 -2
- package/docs/content-org_playlists-types.js.html +2 -2
- package/docs/content-org_playlists.js.html +2 -2
- package/docs/content.js.html +88 -10
- package/docs/content_artist.ts.html +8 -8
- package/docs/content_genre.ts.html +18 -15
- package/docs/content_instructor.ts.html +21 -16
- package/docs/forums_categories.ts.html +21 -2
- package/docs/forums_forums.ts.html +2 -2
- package/docs/forums_posts.ts.html +2 -2
- package/docs/forums_threads.ts.html +2 -2
- package/docs/gamification_awards.ts.html +2 -2
- package/docs/gamification_gamification.js.html +2 -2
- package/docs/global.html +2 -2
- package/docs/index.html +2 -2
- package/docs/liveTesting.ts.html +2 -2
- package/docs/module-Accounts.html +2 -2
- package/docs/module-Artist.html +8 -8
- package/docs/module-Awards.html +2 -2
- package/docs/module-Config.html +5 -4
- package/docs/module-Content-Services-V2.html +440 -9
- package/docs/module-Forums.html +607 -43
- package/docs/module-Genre.html +9 -9
- package/docs/module-GuidedCourses.html +2 -2
- package/docs/module-Instructor.html +6 -6
- package/docs/module-Interests.html +2 -2
- package/docs/module-LearningPaths.html +2 -2
- package/docs/module-Onboarding.html +2 -2
- package/docs/module-Payments.html +2 -2
- package/docs/module-Permissions.html +2 -2
- package/docs/module-Playlists.html +2 -2
- package/docs/module-ProgressRow.html +2 -2
- package/docs/module-Railcontent-Services.html +2 -2
- package/docs/module-Sanity-Services.html +320 -15
- package/docs/module-Sessions.html +2 -2
- package/docs/module-UserActivity.html +2 -2
- package/docs/module-UserChat.html +2 -2
- package/docs/module-UserManagement.html +2 -2
- package/docs/module-UserMemberships.html +2 -2
- package/docs/module-UserNotifications.html +2 -2
- package/docs/module-UserProfile.html +2 -2
- package/docs/progress-row_method-card.js.html +2 -2
- package/docs/railcontent.js.html +2 -2
- package/docs/sanity.js.html +105 -42
- package/docs/userActivity.js.html +2 -2
- package/docs/user_account.ts.html +2 -2
- package/docs/user_chat.js.html +2 -2
- package/docs/user_interests.js.html +2 -2
- package/docs/user_management.js.html +2 -2
- package/docs/user_memberships.ts.html +2 -2
- package/docs/user_notifications.js.html +2 -2
- package/docs/user_onboarding.ts.html +2 -2
- package/docs/user_payments.ts.html +2 -2
- package/docs/user_permissions.js.html +3 -3
- package/docs/user_profile.js.html +2 -2
- package/docs/user_sessions.js.html +2 -2
- package/docs/user_types.js.html +2 -2
- package/docs/user_user-management-system.js.html +2 -2
- package/package.json +1 -1
- package/src/contentTypeConfig.js +33 -1
- package/src/filterBuilder.js +22 -12
- package/src/index.d.ts +6 -0
- package/src/index.js +6 -0
- package/src/lib/lastUpdated.js +4 -4
- package/src/services/config.js +3 -0
- package/src/services/content/artist.ts +6 -6
- package/src/services/content/genre.ts +16 -13
- package/src/services/content/instructor.ts +19 -14
- package/src/services/content.js +86 -8
- package/src/services/contentAggregator.js +4 -4
- package/src/services/contentProgress.js +16 -3
- package/src/services/forums/categories.ts +19 -0
- package/src/services/permissions/PermissionsAdapter.ts +111 -0
- package/src/services/permissions/PermissionsAdapterFactory.ts +71 -0
- package/src/services/permissions/PermissionsV1Adapter.ts +232 -0
- package/src/services/permissions/PermissionsV2Adapter.ts +226 -0
- package/src/services/permissions/README.md +139 -0
- package/src/services/permissions/index.ts +65 -0
- package/src/services/sanity.js +103 -40
- package/src/services/types.js +1 -0
- package/src/services/user/permissions.js +1 -1
- package/test/content.test.js +5 -0
- package/test/forum.test.js +1 -1
- package/test/initializeTests.js +5 -3
- 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
|