musora-content-services 2.160.3 → 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/.claude/settings.local.json +23 -0
- package/CHANGELOG.md +17 -0
- package/package.json +1 -1
- package/src/contentTypeConfig.js +14 -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/sanity.js +2 -2
- package/src/services/user/profile.ts +66 -0
- package/test/live/content.test.js +116 -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/test/unit/sanityQueryService.test.ts +11 -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.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"permissions": {
|
|
3
|
+
"allow": [
|
|
4
|
+
"Bash(npx jest *)",
|
|
5
|
+
"Bash(npx tsc *)",
|
|
6
|
+
"Skill(counselors)",
|
|
7
|
+
"Bash(counselors ls *)",
|
|
8
|
+
"Bash(counselors groups *)",
|
|
9
|
+
"Bash(counselors run *)",
|
|
10
|
+
"Bash(npm test *)",
|
|
11
|
+
"Bash(gh pr *)",
|
|
12
|
+
"Bash(gh api *)",
|
|
13
|
+
"Bash(mkdir -p /tmp/pr-review-v2)",
|
|
14
|
+
"Read(//tmp/pr-review-v2/**)",
|
|
15
|
+
"Bash(cat /home/alesevero/railenvironment/applications/musora-content-services/AGENTS.md)",
|
|
16
|
+
"Bash(echo \"no AGENTS.md\")",
|
|
17
|
+
"Bash(echo \"exit=$?\")",
|
|
18
|
+
"Bash(git checkout *)",
|
|
19
|
+
"Skill(pr)",
|
|
20
|
+
"Skill(create-decision)"
|
|
21
|
+
]
|
|
22
|
+
}
|
|
23
|
+
}
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,23 @@
|
|
|
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
|
+
|
|
13
|
+
### [2.160.4](https://github.com/railroadmedia/musora-content-services/compare/v2.160.3...v2.160.4) (2026-05-20)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
### Bug Fixes
|
|
17
|
+
|
|
18
|
+
* **MU2-1510:** process need access for live events ([#964](https://github.com/railroadmedia/musora-content-services/issues/964)) ([66aa2b5](https://github.com/railroadmedia/musora-content-services/commit/66aa2b596b28c272b938672522b9c4024f9e328d))
|
|
19
|
+
* **MU2-1511:** add need access to upcoming live events ([#965](https://github.com/railroadmedia/musora-content-services/issues/965)) ([9366907](https://github.com/railroadmedia/musora-content-services/commit/936690752529d25d80df0192e80b57ad90921445))
|
|
20
|
+
* **MU2-1512:** add child data to new and upcoming ([#960](https://github.com/railroadmedia/musora-content-services/issues/960)) ([329f5c8](https://github.com/railroadmedia/musora-content-services/commit/329f5c8b98ea11f1d456dd53e81b705f55b4f346))
|
|
21
|
+
|
|
5
22
|
### [2.160.3](https://github.com/railroadmedia/musora-content-services/compare/v2.160.2...v2.160.3) (2026-05-14)
|
|
6
23
|
|
|
7
24
|
|
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,8 @@ export let contentTypeConfig = {
|
|
|
694
695
|
}`,
|
|
695
696
|
],
|
|
696
697
|
'new-and-scheduled': {
|
|
697
|
-
fields: [
|
|
698
|
-
|
|
699
|
-
isLiveField(),
|
|
700
|
-
],
|
|
698
|
+
fields: ['show_in_new_feed', isLiveField()],
|
|
699
|
+
includeChildFields: true,
|
|
701
700
|
},
|
|
702
701
|
}
|
|
703
702
|
|
|
@@ -815,7 +814,7 @@ export function artistOrInstructorNameAsArray(key = 'artists') {
|
|
|
815
814
|
|
|
816
815
|
export async function getFieldsForContentTypeWithFilteredChildren(
|
|
817
816
|
contentType,
|
|
818
|
-
asQueryString = true
|
|
817
|
+
asQueryString = true
|
|
819
818
|
) {
|
|
820
819
|
const childFields = getChildFieldsForContentType(contentType, true)
|
|
821
820
|
const parentFields = getFieldsForContentType(contentType, false)
|
|
@@ -830,7 +829,7 @@ export async function getFieldsForContentTypeWithFilteredChildren(
|
|
|
830
829
|
"children": child[${childFilter}]->{
|
|
831
830
|
${childFields}
|
|
832
831
|
},
|
|
833
|
-
}
|
|
832
|
+
}`
|
|
834
833
|
)
|
|
835
834
|
}
|
|
836
835
|
return asQueryString ? parentFields.toString() + ',' : parentFields
|
|
@@ -920,7 +919,7 @@ const filterHandlers = {
|
|
|
920
919
|
length: (value) => {
|
|
921
920
|
// Find the matching length option by name
|
|
922
921
|
const lengthOption = Object.values(LengthFilterOptions).find(
|
|
923
|
-
(opt) => typeof opt === 'object' && opt.name === value
|
|
922
|
+
(opt) => typeof opt === 'object' && opt.name === value
|
|
924
923
|
)
|
|
925
924
|
|
|
926
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
|
+
|