musora-content-services 2.160.4 → 2.160.5
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/.agent/decisions/2026-05-20-live-event-fetch-permissions-id.md +23 -0
- package/CHANGELOG.md +8 -0
- package/package.json +1 -1
- package/src/contentTypeConfig.js +13 -15
- package/src/index.d.ts +6 -2
- package/src/index.js +6 -2
- package/src/infrastructure/sanity/README.md +230 -0
- package/src/infrastructure/sanity/SanityClient.ts +105 -0
- package/src/infrastructure/sanity/clients/ContentClient.ts +164 -0
- package/src/infrastructure/sanity/examples/usage.ts +101 -0
- package/src/infrastructure/sanity/executors/FetchQueryExecutor.ts +110 -0
- package/src/infrastructure/sanity/index.ts +19 -0
- package/src/infrastructure/sanity/interfaces/ConfigProvider.ts +6 -0
- package/src/infrastructure/sanity/interfaces/FetchByIdOptions.ts +7 -0
- package/src/infrastructure/sanity/interfaces/QueryExecutor.ts +8 -0
- package/src/infrastructure/sanity/interfaces/SanityConfig.ts +10 -0
- package/src/infrastructure/sanity/interfaces/SanityError.ts +7 -0
- package/src/infrastructure/sanity/interfaces/SanityQuery.ts +5 -0
- package/src/infrastructure/sanity/interfaces/SanityResponse.ts +6 -0
- package/src/infrastructure/sanity/providers/DefaultConfigProvider.ts +38 -0
- package/src/lib/sanity/decorators/base.ts +142 -0
- package/src/lib/sanity/decorators/examples.ts +229 -0
- package/src/lib/sanity/decorators/navigate-to.ts +139 -0
- package/src/lib/sanity/decorators/need-access.ts +40 -0
- package/src/lib/sanity/decorators/page-type.ts +35 -0
- package/src/services/awards/award-query.js +71 -0
- package/src/services/contentAggregator.js +1 -1
- package/src/services/multi-user-accounts/multi-user-accounts.ts +2 -0
- package/src/services/user/profile.ts +66 -0
- package/test/unit/infrastructure/sanity/ContentClient.test.ts +168 -0
- package/test/unit/infrastructure/sanity/DefaultConfigProvider.test.ts +93 -0
- package/test/unit/infrastructure/sanity/FetchQueryExecutor.test.ts +174 -0
- package/test/unit/infrastructure/sanity/SanityClient.test.ts +140 -0
- package/test/unit/lib/sanity/decorators/base.test.ts +368 -0
- package/test/unit/lib/sanity/decorators/navigate-to.test.ts +266 -0
- package/test/unit/lib/sanity/decorators/need-access.test.ts +89 -0
- package/test/unit/lib/sanity/decorators/page-type.test.ts +81 -0
- package/src/services/user/profile.js +0 -43
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
---
|
|
2
|
+
date: 2026-05-20
|
|
3
|
+
branch: fix/live-event-fetch-permissions-id
|
|
4
|
+
pr: https://github.com/railroadmedia/musora-content-services/pull/982
|
|
5
|
+
status: open
|
|
6
|
+
tags: [bug-fix]
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# Include permission_id in live event minimum fields
|
|
10
|
+
|
|
11
|
+
## Context
|
|
12
|
+
Live event consumers downstream of `getLiveFields()` need `permission_id` to resolve access to events. Without it on the returned payload, callers had to issue an extra query (or fall back to defaults) to determine whether the current user is permitted to view a given live event.
|
|
13
|
+
|
|
14
|
+
## Decision
|
|
15
|
+
Add `'permission_id'` to the `minimumFields` array in `getLiveFields()` in `src/contentTypeConfig.js` so the field is projected from Sanity alongside the other core live event fields (`live_event_start_time`, `live_event_end_time`, `live_event_stream_id`, `vimeo_live_event_id`, etc.).
|
|
16
|
+
|
|
17
|
+
## Alternatives Considered
|
|
18
|
+
- Put `permission_id` in `additionalFields` instead of `minimumFields`. Rejected — callers requesting the minimum projection are the ones that gate playback, so they need permission resolution by default.
|
|
19
|
+
- Fetch `permission_id` in a separate query at the consumer layer. Rejected — duplicates Sanity round-trips and spreads access logic.
|
|
20
|
+
|
|
21
|
+
## Consequences
|
|
22
|
+
- All live event responses produced via `getLiveFields()` now include `permission_id`.
|
|
23
|
+
- Incidental formatting (single-quote and trailing-comma normalization) was applied to the same file by the editor — not behavioral.
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
|
|
4
4
|
|
|
5
|
+
### [2.160.5](https://github.com/railroadmedia/musora-content-services/compare/v2.160.4...v2.160.5) (2026-05-22)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
### Bug Fixes
|
|
9
|
+
|
|
10
|
+
* add a null check for method intro video ([#983](https://github.com/railroadmedia/musora-content-services/issues/983)) ([dbcaa72](https://github.com/railroadmedia/musora-content-services/commit/dbcaa7250ccca71ddcefda0d0514a7a013055784))
|
|
11
|
+
* **live-events:** fetch permission ids ([#982](https://github.com/railroadmedia/musora-content-services/issues/982)) ([cd12054](https://github.com/railroadmedia/musora-content-services/commit/cd12054a58a0752614f8cdebcf79750edae948c3))
|
|
12
|
+
|
|
5
13
|
### [2.160.4](https://github.com/railroadmedia/musora-content-services/compare/v2.160.3...v2.160.4) (2026-05-20)
|
|
6
14
|
|
|
7
15
|
|
package/package.json
CHANGED
package/src/contentTypeConfig.js
CHANGED
|
@@ -146,22 +146,23 @@ export const assignmentsField = `"assignments":assignment[]{
|
|
|
146
146
|
// todo: refactor live event queries to use this
|
|
147
147
|
export function getLiveFields(minimum = false) {
|
|
148
148
|
const minimumFields = [
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
149
|
+
'live_event_start_time',
|
|
150
|
+
'live_event_end_time',
|
|
151
|
+
'live_event_stream_id',
|
|
152
|
+
'vimeo_live_event_id',
|
|
153
153
|
"'live_event_is_global': live_global_event == true",
|
|
154
154
|
"'videoId': coalesce(live_event_stream_id, video.external_id)",
|
|
155
|
+
"'permission_id': permission_v2",
|
|
155
156
|
]
|
|
156
157
|
const additionalFields = [
|
|
157
158
|
"'slug': slug.current",
|
|
158
159
|
"'id': railcontent_id",
|
|
159
|
-
|
|
160
|
-
|
|
160
|
+
'title',
|
|
161
|
+
'published_on',
|
|
161
162
|
"'thumbnail': thumbnail.asset->url",
|
|
162
163
|
`${artistOrInstructorName()}`,
|
|
163
|
-
|
|
164
|
-
|
|
164
|
+
'difficulty_string',
|
|
165
|
+
'railcontent_id',
|
|
165
166
|
`'instructors': ${instructorField}`,
|
|
166
167
|
]
|
|
167
168
|
|
|
@@ -694,10 +695,7 @@ export let contentTypeConfig = {
|
|
|
694
695
|
}`,
|
|
695
696
|
],
|
|
696
697
|
'new-and-scheduled': {
|
|
697
|
-
fields: [
|
|
698
|
-
'show_in_new_feed',
|
|
699
|
-
isLiveField(),
|
|
700
|
-
],
|
|
698
|
+
fields: ['show_in_new_feed', isLiveField()],
|
|
701
699
|
includeChildFields: true,
|
|
702
700
|
},
|
|
703
701
|
}
|
|
@@ -816,7 +814,7 @@ export function artistOrInstructorNameAsArray(key = 'artists') {
|
|
|
816
814
|
|
|
817
815
|
export async function getFieldsForContentTypeWithFilteredChildren(
|
|
818
816
|
contentType,
|
|
819
|
-
asQueryString = true
|
|
817
|
+
asQueryString = true
|
|
820
818
|
) {
|
|
821
819
|
const childFields = getChildFieldsForContentType(contentType, true)
|
|
822
820
|
const parentFields = getFieldsForContentType(contentType, false)
|
|
@@ -831,7 +829,7 @@ export async function getFieldsForContentTypeWithFilteredChildren(
|
|
|
831
829
|
"children": child[${childFilter}]->{
|
|
832
830
|
${childFields}
|
|
833
831
|
},
|
|
834
|
-
}
|
|
832
|
+
}`
|
|
835
833
|
)
|
|
836
834
|
}
|
|
837
835
|
return asQueryString ? parentFields.toString() + ',' : parentFields
|
|
@@ -921,7 +919,7 @@ const filterHandlers = {
|
|
|
921
919
|
length: (value) => {
|
|
922
920
|
// Find the matching length option by name
|
|
923
921
|
const lengthOption = Object.values(LengthFilterOptions).find(
|
|
924
|
-
(opt) => typeof opt === 'object' && opt.name === value
|
|
922
|
+
(opt) => typeof opt === 'object' && opt.name === value
|
|
925
923
|
)
|
|
926
924
|
|
|
927
925
|
if (!lengthOption) return ''
|
package/src/index.d.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
getAwardStatistics,
|
|
10
10
|
getBadgeFields,
|
|
11
11
|
getCompletedAwards,
|
|
12
|
+
getCompletedAwardsByUser,
|
|
12
13
|
getContentAwards,
|
|
13
14
|
getContentAwardsByIds,
|
|
14
15
|
getInProgressAwards,
|
|
@@ -455,8 +456,9 @@ import {
|
|
|
455
456
|
|
|
456
457
|
import {
|
|
457
458
|
deleteProfilePicture,
|
|
458
|
-
otherStats
|
|
459
|
-
|
|
459
|
+
otherStats,
|
|
460
|
+
updateProfileVisibility
|
|
461
|
+
} from './services/user/profile.ts';
|
|
460
462
|
|
|
461
463
|
import {
|
|
462
464
|
generateAuthSessionUrl,
|
|
@@ -665,6 +667,7 @@ declare module 'musora-content-services' {
|
|
|
665
667
|
getAwardStatistics,
|
|
666
668
|
getBadgeFields,
|
|
667
669
|
getCompletedAwards,
|
|
670
|
+
getCompletedAwardsByUser,
|
|
668
671
|
getContentAwards,
|
|
669
672
|
getContentAwardsByIds,
|
|
670
673
|
getContentRows,
|
|
@@ -828,6 +831,7 @@ declare module 'musora-content-services' {
|
|
|
828
831
|
updatePlaylist,
|
|
829
832
|
updatePost,
|
|
830
833
|
updatePracticeNotes,
|
|
834
|
+
updateProfileVisibility,
|
|
831
835
|
updateThread,
|
|
832
836
|
updateUserPractice,
|
|
833
837
|
upgradeSubscription,
|
package/src/index.js
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
getAwardStatistics,
|
|
14
14
|
getBadgeFields,
|
|
15
15
|
getCompletedAwards,
|
|
16
|
+
getCompletedAwardsByUser,
|
|
16
17
|
getContentAwards,
|
|
17
18
|
getContentAwardsByIds,
|
|
18
19
|
getInProgressAwards,
|
|
@@ -459,8 +460,9 @@ import {
|
|
|
459
460
|
|
|
460
461
|
import {
|
|
461
462
|
deleteProfilePicture,
|
|
462
|
-
otherStats
|
|
463
|
-
|
|
463
|
+
otherStats,
|
|
464
|
+
updateProfileVisibility
|
|
465
|
+
} from './services/user/profile.ts';
|
|
464
466
|
|
|
465
467
|
import {
|
|
466
468
|
generateAuthSessionUrl,
|
|
@@ -664,6 +666,7 @@ export {
|
|
|
664
666
|
getAwardStatistics,
|
|
665
667
|
getBadgeFields,
|
|
666
668
|
getCompletedAwards,
|
|
669
|
+
getCompletedAwardsByUser,
|
|
667
670
|
getContentAwards,
|
|
668
671
|
getContentAwardsByIds,
|
|
669
672
|
getContentRows,
|
|
@@ -827,6 +830,7 @@ export {
|
|
|
827
830
|
updatePlaylist,
|
|
828
831
|
updatePost,
|
|
829
832
|
updatePracticeNotes,
|
|
833
|
+
updateProfileVisibility,
|
|
830
834
|
updateThread,
|
|
831
835
|
updateUserPractice,
|
|
832
836
|
upgradeSubscription,
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
# Sanity Infrastructure
|
|
2
|
+
|
|
3
|
+
This module provides a TypeScript-based infrastructure for interacting with Sanity CMS, following the same architectural patterns as the HTTP client.
|
|
4
|
+
|
|
5
|
+
## Architecture
|
|
6
|
+
|
|
7
|
+
The Sanity infrastructure follows a modular design with clear separation of concerns:
|
|
8
|
+
|
|
9
|
+
- **SanityClient**: Base client class that provides low-level methods for executing raw GROQ queries
|
|
10
|
+
- **ContentClient**: Specialized client that extends SanityClient with content-specific methods like `fetchById`
|
|
11
|
+
- **Interfaces**: Define contracts for configuration, queries, responses, and errors
|
|
12
|
+
- **Providers**: Handle configuration management
|
|
13
|
+
- **Executors**: Handle the actual execution of queries against the Sanity API
|
|
14
|
+
|
|
15
|
+
## Usage
|
|
16
|
+
|
|
17
|
+
### Basic Usage
|
|
18
|
+
|
|
19
|
+
```typescript
|
|
20
|
+
import { SanityClient, ContentClient } from './infrastructure/sanity'
|
|
21
|
+
|
|
22
|
+
// Create client instances (use global configuration automatically)
|
|
23
|
+
const sanityClient = new SanityClient() // For raw GROQ queries
|
|
24
|
+
const contentClient = new ContentClient() // For content-specific operations
|
|
25
|
+
|
|
26
|
+
// Fetch a single document by type and ID (recommended approach)
|
|
27
|
+
const song = await contentClient.fetchById({
|
|
28
|
+
type: 'song',
|
|
29
|
+
id: 123
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
// Fetch with custom fields
|
|
33
|
+
const songWithCustomFields = await contentClient.fetchById({
|
|
34
|
+
type: 'song',
|
|
35
|
+
id: 123,
|
|
36
|
+
fields: [
|
|
37
|
+
"'id': railcontent_id",
|
|
38
|
+
'title',
|
|
39
|
+
"'artist': artist->name",
|
|
40
|
+
'album',
|
|
41
|
+
'difficulty_string'
|
|
42
|
+
]
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
// Fetch with children (for courses, packs, etc.)
|
|
46
|
+
const courseWithLessons = await contentClient.fetchById({
|
|
47
|
+
type: 'course',
|
|
48
|
+
id: 456,
|
|
49
|
+
includeChildren: true
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
// Fetch multiple content items by IDs
|
|
53
|
+
const multipleSongs = await contentClient.fetchByIds([123, 456, 789], 'song', 'drumeo')
|
|
54
|
+
|
|
55
|
+
// Fetch content by brand and type
|
|
56
|
+
const drumeoSongs = await contentClient.fetchByBrandAndType('drumeo', 'song', {
|
|
57
|
+
limit: 20,
|
|
58
|
+
sortBy: 'published_on desc'
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
// Use base SanityClient for raw GROQ queries (for complex queries)
|
|
62
|
+
const songs = await sanityClient.fetchList(`
|
|
63
|
+
*[_type == "song" && brand == "drumeo"] | order(published_on desc)[0...10]{
|
|
64
|
+
"id": railcontent_id,
|
|
65
|
+
title,
|
|
66
|
+
"artist": artist->name
|
|
67
|
+
}
|
|
68
|
+
`)
|
|
69
|
+
|
|
70
|
+
// Execute complex queries with base client
|
|
71
|
+
const result = await sanityClient.executeQuery(`
|
|
72
|
+
{
|
|
73
|
+
"songs": *[_type == "song"][0...5],
|
|
74
|
+
"total": count(*[_type == "song"])
|
|
75
|
+
}
|
|
76
|
+
`)
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### Custom Configuration
|
|
80
|
+
|
|
81
|
+
```typescript
|
|
82
|
+
import { SanityClient, ContentClient, ConfigProvider, SanityConfig } from './infrastructure/sanity'
|
|
83
|
+
|
|
84
|
+
// Use custom configuration provider
|
|
85
|
+
class CustomConfigProvider implements ConfigProvider {
|
|
86
|
+
getConfig(): SanityConfig {
|
|
87
|
+
return {
|
|
88
|
+
projectId: 'custom-project',
|
|
89
|
+
dataset: 'production',
|
|
90
|
+
version: '2021-06-07',
|
|
91
|
+
token: 'custom-token',
|
|
92
|
+
perspective: 'published',
|
|
93
|
+
useCachedAPI: true,
|
|
94
|
+
debug: false
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const customConfigProvider = new CustomConfigProvider()
|
|
100
|
+
const sanityClient = new SanityClient(customConfigProvider)
|
|
101
|
+
const contentClient = new ContentClient(customConfigProvider)
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### Error Handling
|
|
105
|
+
|
|
106
|
+
```typescript
|
|
107
|
+
try {
|
|
108
|
+
const result = await sanityClient.fetchSingle('*[_type == "song"][0]')
|
|
109
|
+
} catch (error) {
|
|
110
|
+
if (error.query) {
|
|
111
|
+
console.error('Query failed:', error.query)
|
|
112
|
+
console.error('Error:', error.message)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## Configuration
|
|
118
|
+
|
|
119
|
+
The SanityClient uses the global configuration from the config service by default. Ensure your application is initialized with proper Sanity configuration:
|
|
120
|
+
|
|
121
|
+
```javascript
|
|
122
|
+
import { initializeService } from './services/config'
|
|
123
|
+
|
|
124
|
+
initializeService({
|
|
125
|
+
sanityConfig: {
|
|
126
|
+
token: 'your-sanity-api-token',
|
|
127
|
+
projectId: 'your-sanity-project-id',
|
|
128
|
+
dataset: 'your-dataset-name',
|
|
129
|
+
version: '2021-06-07',
|
|
130
|
+
perspective: 'published',
|
|
131
|
+
useCachedAPI: false,
|
|
132
|
+
debug: false
|
|
133
|
+
},
|
|
134
|
+
// ... other config
|
|
135
|
+
})
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## API Reference
|
|
139
|
+
|
|
140
|
+
### SanityClient (Base Client)
|
|
141
|
+
|
|
142
|
+
#### Methods
|
|
143
|
+
|
|
144
|
+
- `fetchSingle<T>(query: string, params?: Record<string, any>): Promise<T | null>`
|
|
145
|
+
- Executes a query and returns the first result
|
|
146
|
+
|
|
147
|
+
- `fetchList<T>(query: string, params?: Record<string, any>): Promise<T[]>`
|
|
148
|
+
- Executes a query and returns all results as an array
|
|
149
|
+
|
|
150
|
+
- `executeQuery<T>(query: string, params?: Record<string, any>): Promise<T | null>`
|
|
151
|
+
- Executes a raw query and returns the full response
|
|
152
|
+
|
|
153
|
+
- `refreshConfig(): void`
|
|
154
|
+
- Refreshes the configuration (useful if global config changes)
|
|
155
|
+
|
|
156
|
+
### ContentClient (Specialized Client)
|
|
157
|
+
|
|
158
|
+
#### Methods
|
|
159
|
+
|
|
160
|
+
- `fetchById<T>(options: FetchByIdOptions): Promise<T | null>`
|
|
161
|
+
- Fetches a single document by type and ID (recommended for simple lookups)
|
|
162
|
+
- Options: `{ type: string, id: number | string, fields?: string[], includeChildren?: boolean }`
|
|
163
|
+
|
|
164
|
+
- `fetchByIds<T>(ids: (number | string)[], type?: string, brand?: string, fields?: string[]): Promise<T[]>`
|
|
165
|
+
- Fetches multiple content items by their IDs
|
|
166
|
+
- Results are sorted to match the order of input IDs
|
|
167
|
+
|
|
168
|
+
- `fetchByBrandAndType<T>(brand: string, type: string, options?: {...}): Promise<T[]>`
|
|
169
|
+
- Fetches content by brand and type with basic filtering
|
|
170
|
+
- Options: `{ limit?: number, offset?: number, sortBy?: string, fields?: string[] }`
|
|
171
|
+
|
|
172
|
+
- All methods from SanityClient (inherited)
|
|
173
|
+
|
|
174
|
+
### Interfaces
|
|
175
|
+
|
|
176
|
+
- `SanityConfig`: Configuration structure for Sanity connection
|
|
177
|
+
- `SanityQuery`: Structure for GROQ queries
|
|
178
|
+
- `SanityResponse<T>`: Structure for Sanity API responses
|
|
179
|
+
- `SanityError`: Structure for Sanity-specific errors
|
|
180
|
+
- `QueryExecutor`: Interface for query execution implementations
|
|
181
|
+
- `ConfigProvider`: Interface for configuration providers
|
|
182
|
+
|
|
183
|
+
## Examples
|
|
184
|
+
|
|
185
|
+
See the `examples/usage.ts` file for comprehensive usage examples.
|
|
186
|
+
|
|
187
|
+
## Migration from Legacy Code
|
|
188
|
+
|
|
189
|
+
To migrate from the existing `fetchByRailContentId` function:
|
|
190
|
+
|
|
191
|
+
```typescript
|
|
192
|
+
// Old way
|
|
193
|
+
import { fetchByRailContentId } from './services/sanity'
|
|
194
|
+
const result = await fetchByRailContentId(123, 'song')
|
|
195
|
+
|
|
196
|
+
// New way (recommended)
|
|
197
|
+
import { ContentClient } from './infrastructure/sanity'
|
|
198
|
+
const contentClient = new ContentClient()
|
|
199
|
+
const result = await contentClient.fetchById({ type: 'song', id: 123 })
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
To migrate from the existing `fetchByRailContentIds` function:
|
|
203
|
+
|
|
204
|
+
```typescript
|
|
205
|
+
// Old way
|
|
206
|
+
import { fetchByRailContentIds } from './services/sanity'
|
|
207
|
+
const results = await fetchByRailContentIds([123, 456, 789], 'song', 'drumeo')
|
|
208
|
+
|
|
209
|
+
// New way
|
|
210
|
+
import { ContentClient } from './infrastructure/sanity'
|
|
211
|
+
const contentClient = new ContentClient()
|
|
212
|
+
const results = await contentClient.fetchByIds([123, 456, 789], 'song', 'drumeo')
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
To migrate from the existing `fetchSanity` function:
|
|
216
|
+
|
|
217
|
+
```typescript
|
|
218
|
+
// Old way
|
|
219
|
+
import { fetchSanity } from './services/sanity'
|
|
220
|
+
const result = await fetchSanity(query, isList)
|
|
221
|
+
|
|
222
|
+
// New way
|
|
223
|
+
import { SanityClient } from './infrastructure/sanity'
|
|
224
|
+
const sanityClient = new SanityClient()
|
|
225
|
+
const result = isList
|
|
226
|
+
? await sanityClient.fetchList(query)
|
|
227
|
+
: await sanityClient.fetchSingle(query)
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
The new architecture provides better type safety, error handling, separation of concerns, and follows modern architectural patterns.
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { ConfigProvider } from './interfaces/ConfigProvider'
|
|
2
|
+
import { QueryExecutor } from './interfaces/QueryExecutor'
|
|
3
|
+
import { SanityQuery } from './interfaces/SanityQuery'
|
|
4
|
+
import { SanityConfig } from './interfaces/SanityConfig'
|
|
5
|
+
import { SanityError } from './interfaces/SanityError'
|
|
6
|
+
import { DefaultConfigProvider } from './providers/DefaultConfigProvider'
|
|
7
|
+
import { FetchQueryExecutor } from './executors/FetchQueryExecutor'
|
|
8
|
+
|
|
9
|
+
export class SanityClient {
|
|
10
|
+
private configProvider: ConfigProvider
|
|
11
|
+
private queryExecutor: QueryExecutor
|
|
12
|
+
private config: SanityConfig | null = null
|
|
13
|
+
|
|
14
|
+
constructor(
|
|
15
|
+
configProvider: ConfigProvider = new DefaultConfigProvider(),
|
|
16
|
+
queryExecutor: QueryExecutor = new FetchQueryExecutor()
|
|
17
|
+
) {
|
|
18
|
+
this.configProvider = configProvider
|
|
19
|
+
this.queryExecutor = queryExecutor
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Execute a GROQ query and return a single result
|
|
24
|
+
*/
|
|
25
|
+
public async fetchSingle<T>(query: string, params?: Record<string, any>): Promise<T | null> {
|
|
26
|
+
try {
|
|
27
|
+
const sanityQuery: SanityQuery = { query, params }
|
|
28
|
+
const response = await this.queryExecutor.execute<T[]>(sanityQuery, this.getConfig())
|
|
29
|
+
|
|
30
|
+
if (response.result && Array.isArray(response.result) && response.result.length > 0) {
|
|
31
|
+
return response.result[0]
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return null
|
|
35
|
+
} catch (error: any) {
|
|
36
|
+
this.handleError(error, query)
|
|
37
|
+
return null
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Execute a GROQ query and return multiple results
|
|
43
|
+
*/
|
|
44
|
+
public async fetchList<T>(query: string, params?: Record<string, any>): Promise<T[]> {
|
|
45
|
+
try {
|
|
46
|
+
const sanityQuery: SanityQuery = { query, params }
|
|
47
|
+
const response = await this.queryExecutor.execute<T[]>(sanityQuery, this.getConfig())
|
|
48
|
+
|
|
49
|
+
return response.result || []
|
|
50
|
+
} catch (error: any) {
|
|
51
|
+
this.handleError(error, query)
|
|
52
|
+
return []
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Execute a raw GROQ query and return the full response
|
|
58
|
+
*/
|
|
59
|
+
public async executeQuery<T>(query: string, params?: Record<string, any>): Promise<T | null> {
|
|
60
|
+
try {
|
|
61
|
+
const sanityQuery: SanityQuery = { query, params }
|
|
62
|
+
const response = await this.queryExecutor.execute<T>(sanityQuery, this.getConfig())
|
|
63
|
+
|
|
64
|
+
return response.result
|
|
65
|
+
} catch (error: any) {
|
|
66
|
+
this.handleError(error, query)
|
|
67
|
+
return null
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Get configuration, loading it if necessary
|
|
73
|
+
*/
|
|
74
|
+
private getConfig(): SanityConfig {
|
|
75
|
+
if (!this.config) {
|
|
76
|
+
this.config = this.configProvider.getConfig()
|
|
77
|
+
}
|
|
78
|
+
return this.config
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Handle and rethrow errors
|
|
83
|
+
*/
|
|
84
|
+
private handleError(error: any, query: string): never {
|
|
85
|
+
if ('message' in error && 'query' in error) {
|
|
86
|
+
// This is already a SanityError
|
|
87
|
+
throw error as SanityError
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Convert to SanityError
|
|
91
|
+
throw {
|
|
92
|
+
message: error.message || 'Sanity query failed',
|
|
93
|
+
query,
|
|
94
|
+
originalError: error,
|
|
95
|
+
} as SanityError
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Refresh the configuration (useful if global config changes)
|
|
100
|
+
*/
|
|
101
|
+
public refreshConfig(): void {
|
|
102
|
+
this.config = null
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { SanityClient } from '../SanityClient'
|
|
2
|
+
import { FetchByIdOptions } from '../interfaces/FetchByIdOptions'
|
|
3
|
+
import { ConfigProvider } from '../interfaces/ConfigProvider'
|
|
4
|
+
import { QueryExecutor } from '../interfaces/QueryExecutor'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* ContentClient extends SanityClient with content-specific methods
|
|
8
|
+
* for easier content fetching and management
|
|
9
|
+
*/
|
|
10
|
+
export class ContentClient extends SanityClient {
|
|
11
|
+
constructor(configProvider?: ConfigProvider, queryExecutor?: QueryExecutor) {
|
|
12
|
+
super(configProvider, queryExecutor)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Fetch content by type and ID (similar to fetchByRailContentId)
|
|
17
|
+
*/
|
|
18
|
+
public async fetchById<T>(options: FetchByIdOptions): Promise<T | null> {
|
|
19
|
+
try {
|
|
20
|
+
const { type, id, fields, includeChildren = false } = options
|
|
21
|
+
|
|
22
|
+
// Build the base query
|
|
23
|
+
let query = `*[railcontent_id == ${id} && _type == '${type}']`
|
|
24
|
+
|
|
25
|
+
// Build fields string
|
|
26
|
+
let fieldsString = this.buildFieldsString(type, fields, includeChildren)
|
|
27
|
+
|
|
28
|
+
// Complete the query
|
|
29
|
+
query += `{${fieldsString}}[0]`
|
|
30
|
+
|
|
31
|
+
return await this.fetchSingle<T>(query)
|
|
32
|
+
} catch (error: any) {
|
|
33
|
+
this.handleContentError(error, `fetchById(${JSON.stringify(options)})`)
|
|
34
|
+
return null
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Fetch multiple content items by their IDs
|
|
40
|
+
*/
|
|
41
|
+
public async fetchByIds<T>(
|
|
42
|
+
ids: (number | string)[],
|
|
43
|
+
type?: string,
|
|
44
|
+
brand?: string,
|
|
45
|
+
fields?: string[]
|
|
46
|
+
): Promise<T[]> {
|
|
47
|
+
try {
|
|
48
|
+
if (!ids || ids.length === 0) {
|
|
49
|
+
return []
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const idsString = ids.join(',')
|
|
53
|
+
const typeFilter = type ? ` && _type == '${type}'` : ''
|
|
54
|
+
const brandFilter = brand ? ` && brand == "${brand}"` : ''
|
|
55
|
+
const fieldsString = this.buildFieldsString(type || '', fields, false)
|
|
56
|
+
|
|
57
|
+
const query = `*[railcontent_id in [${idsString}]${typeFilter}${brandFilter}]{${fieldsString}}`
|
|
58
|
+
|
|
59
|
+
const results = await this.fetchList<T>(query)
|
|
60
|
+
|
|
61
|
+
// Sort results to match the order of input IDs
|
|
62
|
+
return results.sort((a: any, b: any) => {
|
|
63
|
+
const indexA = ids.indexOf(a.id || a.railcontent_id)
|
|
64
|
+
const indexB = ids.indexOf(b.id || b.railcontent_id)
|
|
65
|
+
return indexA - indexB
|
|
66
|
+
})
|
|
67
|
+
} catch (error: any) {
|
|
68
|
+
this.handleContentError(error, `fetchByIds([${ids.join(',')}])`)
|
|
69
|
+
return []
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Fetch content by brand and type with basic filtering
|
|
75
|
+
*/
|
|
76
|
+
public async fetchByBrandAndType<T>(
|
|
77
|
+
brand: string,
|
|
78
|
+
type: string,
|
|
79
|
+
options: {
|
|
80
|
+
limit?: number
|
|
81
|
+
offset?: number
|
|
82
|
+
sortBy?: string
|
|
83
|
+
fields?: string[]
|
|
84
|
+
} = {}
|
|
85
|
+
): Promise<T[]> {
|
|
86
|
+
try {
|
|
87
|
+
const { limit = 10, offset = 0, sortBy = 'published_on desc', fields } = options
|
|
88
|
+
const fieldsString = this.buildFieldsString(type, fields, false)
|
|
89
|
+
|
|
90
|
+
const query = `*[brand == "${brand}" && _type == "${type}"] | order(${sortBy})[${offset}...${offset + limit}]{${fieldsString}}`
|
|
91
|
+
|
|
92
|
+
return await this.fetchList<T>(query)
|
|
93
|
+
} catch (error: any) {
|
|
94
|
+
this.handleContentError(error, `fetchByBrandAndType(${brand}, ${type})`)
|
|
95
|
+
return []
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Build fields string for queries based on content type and options
|
|
101
|
+
*/
|
|
102
|
+
private buildFieldsString(type: string, customFields?: string[], includeChildren: boolean = false): string {
|
|
103
|
+
// Default fields that are commonly used
|
|
104
|
+
const defaultFields = [
|
|
105
|
+
"'sanity_id': _id",
|
|
106
|
+
"'id': railcontent_id",
|
|
107
|
+
'railcontent_id',
|
|
108
|
+
'title',
|
|
109
|
+
"'image': thumbnail.asset->url",
|
|
110
|
+
"'thumbnail': thumbnail.asset->url",
|
|
111
|
+
'difficulty',
|
|
112
|
+
'difficulty_string',
|
|
113
|
+
'web_url_path',
|
|
114
|
+
"'url': web_url_path",
|
|
115
|
+
'published_on',
|
|
116
|
+
"'type': _type",
|
|
117
|
+
'brand',
|
|
118
|
+
'status',
|
|
119
|
+
"'slug': slug.current",
|
|
120
|
+
"'permission_id': permission[]->railcontent_id",
|
|
121
|
+
'length_in_seconds',
|
|
122
|
+
"'artist': artist->name",
|
|
123
|
+
"'instructors': instructor[]->name"
|
|
124
|
+
]
|
|
125
|
+
|
|
126
|
+
// Use custom fields if provided, otherwise use defaults
|
|
127
|
+
let fields = customFields || defaultFields
|
|
128
|
+
|
|
129
|
+
// Add children-related fields if requested
|
|
130
|
+
if (includeChildren) {
|
|
131
|
+
fields = [
|
|
132
|
+
...fields,
|
|
133
|
+
"'child_count': coalesce(count(child[]->), 0)",
|
|
134
|
+
`"lessons": child[]->{
|
|
135
|
+
"id": railcontent_id,
|
|
136
|
+
title,
|
|
137
|
+
"image": thumbnail.asset->url,
|
|
138
|
+
"instructors": instructor[]->name,
|
|
139
|
+
length_in_seconds,
|
|
140
|
+
web_url_path
|
|
141
|
+
}`
|
|
142
|
+
]
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return fields.join(',\n ')
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Handle and rethrow errors with additional context
|
|
150
|
+
*/
|
|
151
|
+
private handleContentError(error: any, context: string): never {
|
|
152
|
+
if ('message' in error && 'query' in error) {
|
|
153
|
+
// This is already a SanityError
|
|
154
|
+
throw error
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Convert to SanityError with context
|
|
158
|
+
throw {
|
|
159
|
+
message: error.message || `ContentClient operation failed: ${context}`,
|
|
160
|
+
query: context,
|
|
161
|
+
originalError: error,
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|