musora-content-services 2.176.0 → 2.177.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 (40) hide show
  1. package/.agent/decisions/2026-08-17-password-reset-mobile-token.md +27 -0
  2. package/.claude/settings.local.json +71 -0
  3. package/CHANGELOG.md +15 -0
  4. package/jest.config.js +3 -2
  5. package/package.json +1 -1
  6. package/src/index.d.ts +2 -0
  7. package/src/index.js +2 -0
  8. package/src/lib/ads/coproduct.ts +184 -0
  9. package/src/lib/ads/either.ts +9 -0
  10. package/src/lib/ads/functor.ts +9 -0
  11. package/src/lib/ads/interfaces/droppable.ts +4 -0
  12. package/src/lib/ads/interfaces/foldable.ts +24 -0
  13. package/src/lib/ads/interfaces/recoverable.ts +4 -0
  14. package/src/lib/ads/interfaces/tappable.ts +4 -0
  15. package/src/lib/ads/monad.ts +6 -0
  16. package/src/lib/sanity/examples.ts +204 -0
  17. package/src/lib/sanity/filter.ts +18 -12
  18. package/src/lib/sanity/groq.ts +65 -0
  19. package/src/lib/sanity/query.ts +5 -0
  20. package/src/lib/sanity/runner.ts +64 -0
  21. package/src/services/user/account.ts +12 -19
  22. package/src/services/user/session.ts +10 -2
  23. package/src/version-info.js +1 -1
  24. package/.idea/codeStyles/Project.xml +0 -61
  25. package/.idea/codeStyles/codeStyleConfig.xml +0 -5
  26. package/.idea/dataSources/6549dc28-6466-4ea3-a66b-7750cfeae4e7/storage_v2/_src_/schema/information_schema.FNRwLQ.meta +0 -2
  27. package/.idea/dataSources/6549dc28-6466-4ea3-a66b-7750cfeae4e7/storage_v2/_src_/schema/mysql.osA4Bg.meta +0 -2
  28. package/.idea/dataSources/6549dc28-6466-4ea3-a66b-7750cfeae4e7/storage_v2/_src_/schema/performance_schema.kIw0nw.meta +0 -2
  29. package/.idea/dataSources/6549dc28-6466-4ea3-a66b-7750cfeae4e7/storage_v2/_src_/schema/sys.zb4BAA.meta +0 -2
  30. package/.idea/dataSources/6549dc28-6466-4ea3-a66b-7750cfeae4e7.xml +0 -25767
  31. package/.idea/dataSources/data_sources_history.xml +0 -23
  32. package/.idea/dataSources.local.xml +0 -19
  33. package/.idea/dataSources.xml +0 -12
  34. package/.idea/modules.xml +0 -8
  35. package/.idea/musora-content-services.iml +0 -8
  36. package/.idea/php.xml +0 -19
  37. package/.idea/prettier.xml +0 -6
  38. package/.idea/vcs.xml +0 -6
  39. package/.idea/workspace.xml +0 -343
  40. package/.yarn/install-state.gz +0 -0
@@ -0,0 +1,27 @@
1
+ ---
2
+ date: 2026-08-17
3
+ branch: fix/password-reset-mobile-token
4
+ pr: https://github.com/railroadmedia/musora-content-services/pull/1031
5
+ status: open
6
+ tags: [bug-fix]
7
+ ---
8
+
9
+ # Password reset mobile token and sessionConfig access
10
+
11
+ ## Context
12
+ Mobile app needed `resetPassword` to return an authenticated session directly (avoiding a separate login step after reset), and two OAuth session functions were reading `userId` from a stale top-level `globalConfig.userId` field instead of the current `globalConfig.sessionConfig.userId` shape.
13
+
14
+ ## Decision
15
+ - `resetPassword` (`src/services/user/account.ts`) now accepts optional `deviceName`, `deviceToken`, `platform` on `PasswordResetProps` and returns `Promise<AuthResponse | null>` instead of `Promise<void>`, so mobile can pass device info and receive a session back from the reset call.
16
+ - `listOAuthProviders` and `unlinkOAuthProvider` (`src/services/user/session.ts`) now read `userId` from `globalConfig.sessionConfig.userId`.
17
+
18
+ ## Alternatives Considered
19
+ No alternatives considered.
20
+
21
+ ## Process Notes
22
+ Initial implementation had a bug: the request body sent `token: props.password` instead of `token: props.token`, which would have sent the user's new password as the reset token. Caught and fixed before opening the PR.
23
+
24
+ ## Consequences
25
+ - `resetPassword` callers must handle the new return type (`AuthResponse | null`) instead of `void`.
26
+ - Mobile clients can now log in immediately after a password reset without a separate auth call.
27
+ - OAuth provider list/unlink calls no longer resolve `userId` as `undefined`.
@@ -0,0 +1,71 @@
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
+ "mcp__github__pull_request_read",
22
+ "mcp__github__push_files",
23
+ "Bash(git push *)",
24
+ "mcp__github__create_pull_request",
25
+ "mcp__github__search_pull_requests",
26
+ "Bash(git commit *)",
27
+ "Bash(git add *)",
28
+ "mcp__github__list_pull_requests",
29
+ "mcp__sanity__get_schema",
30
+ "mcp__sanity__query_documents",
31
+ "Bash(git rm *)",
32
+ "Bash(git rebase *)",
33
+ "Bash(git fetch *)",
34
+ "Bash(git --no-pager log --oneline -1)",
35
+ "Bash(git --no-pager log --oneline -5)",
36
+ "Bash(git ls-tree *)",
37
+ "Bash(git --no-pager log --oneline -2)",
38
+ "Bash(git --no-pager diff --stat HEAD~1)",
39
+ "Bash(git check-ignore *)",
40
+ "Bash(python3 -)",
41
+ "Bash(git --no-pager log --oneline -3)",
42
+ "Bash(mkdir *)",
43
+ "Bash(git --no-pager log --oneline origin/main..HEAD)"
44
+ ]
45
+ },
46
+ "model": "sonnet",
47
+ "enabledMcpjsonServers": [
48
+ "atlassian",
49
+ "figma",
50
+ "google-workspace",
51
+ "snowflake",
52
+ "aws",
53
+ "hex",
54
+ "sanity",
55
+ "mysql",
56
+ "slack",
57
+ "langfuse",
58
+ "chrome-devtools",
59
+ "railway",
60
+ "github",
61
+ "asana"
62
+ ],
63
+ "disabledMcpjsonServers": [
64
+ "nightwatch",
65
+ "1password",
66
+ "mysql-staging",
67
+ "mysql-local",
68
+ "mobile"
69
+ ],
70
+ "effortLevel": "medium"
71
+ }
package/CHANGELOG.md CHANGED
@@ -2,6 +2,21 @@
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.177.0](https://github.com/railroadmedia/musora-content-services/compare/v2.176.0...v2.177.0) (2026-08-19)
6
+
7
+
8
+ ### Features
9
+
10
+ * make built GROQ queries runnable with a typed result ([#1036](https://github.com/railroadmedia/musora-content-services/issues/1036)) ([70b45bc](https://github.com/railroadmedia/musora-content-services/commit/70b45bc446d08ce97ac8708840b205c7c35cb5b7)), closes [#1034](https://github.com/railroadmedia/musora-content-services/issues/1034)
11
+ * **query:** add composite() for multi-key GROQ projections ([#1034](https://github.com/railroadmedia/musora-content-services/issues/1034)) ([ea4f2ca](https://github.com/railroadmedia/musora-content-services/commit/ea4f2caf84a59b7a737b7f1ff9e6c9dc32942810))
12
+
13
+
14
+ ### Bug Fixes
15
+
16
+ * **BEH-1590:** login as user on mobile app ([#1033](https://github.com/railroadmedia/musora-content-services/issues/1033)) ([a5f9ef4](https://github.com/railroadmedia/musora-content-services/commit/a5f9ef41ab42b04f961d53bc4cd8e29a89dc4b2b))
17
+ * escape values interpolated into GROQ filter strings ([#1035](https://github.com/railroadmedia/musora-content-services/issues/1035)) ([bc34d4b](https://github.com/railroadmedia/musora-content-services/commit/bc34d4bef8a33a25ea7e93fa51d7d415aefcfe69)), closes [#1034](https://github.com/railroadmedia/musora-content-services/issues/1034)
18
+ * password reset mobile token and sessionConfig access ([#1031](https://github.com/railroadmedia/musora-content-services/issues/1031)) ([0f7633c](https://github.com/railroadmedia/musora-content-services/commit/0f7633c23cd03d9402347200dd66d6243e8a66c5))
19
+
5
20
  ## [2.176.0](https://github.com/railroadmedia/musora-content-services/compare/v2.175.0...v2.176.0) (2026-08-18)
6
21
 
7
22
 
package/jest.config.js CHANGED
@@ -38,6 +38,8 @@ export default {
38
38
  '!src/services/user/interests.js',
39
39
  '!src/services/user/payments.ts',
40
40
  '!src/services/user/chat.js',
41
+ '!src/**/examples.ts',
42
+ '!src/**/examples/**',
41
43
  ],
42
44
 
43
45
  // The directory where Jest should output its coverage files
@@ -165,10 +167,9 @@ export default {
165
167
  'dotenv/config',
166
168
  '<rootDir>/test/setupConsole.js',
167
169
  '<rootDir>/test/setupNetworkGuard.js',
168
- '<rootDir>/test/setupTimers.js'
170
+ '<rootDir>/test/setupTimers.js',
169
171
  ],
170
172
 
171
-
172
173
  // The number of seconds after which a test is considered as slow and reported as such in the results.
173
174
  // slowTestThreshold: 5,
174
175
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "musora-content-services",
3
- "version": "2.176.0",
3
+ "version": "2.177.0",
4
4
  "description": "A package for Musoras content services ",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
package/src/index.d.ts CHANGED
@@ -494,6 +494,7 @@ import {
494
494
 
495
495
  import {
496
496
  listOAuthProviders,
497
+ loginAsUser,
497
498
  redirectToOAuthProvider,
498
499
  unlinkOAuthProvider,
499
500
  verifyOAuthToken
@@ -805,6 +806,7 @@ declare module 'musora-content-services' {
805
806
  listOAuthProviders,
806
807
  lockThread,
807
808
  login,
809
+ loginAsUser,
808
810
  logout,
809
811
  mapContentToParent,
810
812
  markAllNotificationsAsRead,
package/src/index.js CHANGED
@@ -498,6 +498,7 @@ import {
498
498
 
499
499
  import {
500
500
  listOAuthProviders,
501
+ loginAsUser,
501
502
  redirectToOAuthProvider,
502
503
  unlinkOAuthProvider,
503
504
  verifyOAuthToken
@@ -804,6 +805,7 @@ export {
804
805
  listOAuthProviders,
805
806
  lockThread,
806
807
  login,
808
+ loginAsUser,
807
809
  logout,
808
810
  mapContentToParent,
809
811
  markAllNotificationsAsRead,
@@ -0,0 +1,184 @@
1
+ import { Droppable } from './interfaces/droppable'
2
+ import { DisjointFoldable2 } from './interfaces/foldable'
3
+ import { Recoverable } from './interfaces/recoverable'
4
+ import { Tappable } from './interfaces/tappable'
5
+ import { Monad } from './monad'
6
+
7
+ /** A monadic container that represents a value that can be either a left or a right value. */
8
+ export abstract class Coproduct<L, R>
9
+ implements
10
+ Tappable<L | R>,
11
+ DisjointFoldable2<L, R>,
12
+ Droppable<L | R>,
13
+ Recoverable<R>,
14
+ Monad<L | R>
15
+ {
16
+ static left<L, R>(value: L): Coproduct<L, R> {
17
+ return new Left(value)
18
+ }
19
+
20
+ static right<L, R>(value: R): Coproduct<L, R> {
21
+ return new Right(value)
22
+ }
23
+
24
+ abstract isLeft(): this is Left<L, R>
25
+ abstract isRight(): this is Right<L, R>
26
+
27
+ /**
28
+ * @extends Functor
29
+ * Maps the right value of the Coproduct.
30
+ */
31
+ abstract map<T>(fn: (r: R) => T): Coproduct<L, T>
32
+ /**
33
+ * Maps the right value of the Coproduct with an asynchronous function.
34
+ * A rejection propagates: there is no way to build an L from it.
35
+ */
36
+ abstract mapAsync<T>(fn: (r: R) => Promise<T>): Promise<Coproduct<L, T>>
37
+ /**
38
+ * @extends Monad
39
+ * Applies a function to the right value of the Coproduct, returning a new Coproduct.
40
+ */
41
+ abstract flatMap<T>(fn: (r: R) => Coproduct<L, T>): Coproduct<L, T>
42
+
43
+ /** Maps the left value of the Coproduct to a new type. */
44
+ abstract mapLeft<T>(fn: (l: L) => T): Coproduct<T, R>
45
+
46
+ /** Applies a function to the left value of the Coproduct, returning a new Coproduct. */
47
+ abstract flatMapLeft<T>(fn: (l: L) => Coproduct<T, R>): Coproduct<T, R>
48
+
49
+ abstract fold<T, U>(onLeft: (l: L) => T, onRight: (r: R) => U): T | U
50
+ foldMap<T>(initial: T, fn: (acc: T, value: L | R) => T): T {
51
+ return this.fold(
52
+ (l) => fn(initial, l),
53
+ (r) => fn(initial, r)
54
+ )
55
+ }
56
+
57
+ /**
58
+ * Visits the value inside the container if right and applies a function to it without modifying
59
+ * @implements Tappable
60
+ */
61
+ abstract tap(fn: (r: R) => void): this
62
+
63
+ /** Visits the value inside the container if left and applies a function to it without modifying */
64
+ abstract ltap(fn: (l: L) => void): Coproduct<L, R>
65
+ abstract drop(): R | L
66
+ /**
67
+ * Returns the right value if it exists, otherwise returns the provided default value.
68
+ * @implements Recoverable
69
+ */
70
+ abstract recover(defaultValue: R): R
71
+ }
72
+
73
+ export class Left<L, R> extends Coproduct<L, R> {
74
+ constructor(private readonly value: L) {
75
+ super()
76
+ }
77
+
78
+ isLeft(): this is Left<L, R> {
79
+ return true
80
+ }
81
+
82
+ isRight(): this is Right<L, R> {
83
+ return false
84
+ }
85
+
86
+ map<T>(_fn: (r: R) => T): Coproduct<L, T> {
87
+ return new Left(this.value)
88
+ }
89
+
90
+ async mapAsync<T>(_fn: (r: R) => Promise<T>): Promise<Coproduct<L, T>> {
91
+ return new Left(this.value)
92
+ }
93
+
94
+ flatMap<T>(_fn: (r: R) => Coproduct<L, T>): Coproduct<L, T> {
95
+ return new Left(this.value)
96
+ }
97
+
98
+ mapLeft<T>(fn: (l: L) => T): Coproduct<T, R> {
99
+ return new Left(fn(this.value))
100
+ }
101
+
102
+ flatMapLeft<T>(fn: (l: L) => Coproduct<T, R>): Coproduct<T, R> {
103
+ return fn(this.value)
104
+ }
105
+
106
+ fold<T, U>(onLeft: (l: L) => T, _onRight: (r: R) => U): T | U {
107
+ return onLeft(this.value)
108
+ }
109
+
110
+ tap(_fn: (l: R) => void): this {
111
+ return this
112
+ }
113
+
114
+ ltap(fn: (l: L) => void): Coproduct<L, R> {
115
+ const valueToTap = this.value
116
+ fn(valueToTap)
117
+ return this
118
+ }
119
+
120
+ drop(): L | R {
121
+ return this.value
122
+ }
123
+
124
+ recover(defaultValue: R): R {
125
+ return defaultValue
126
+ }
127
+ }
128
+
129
+ /** A right value in an Coproduct, usually representing a success or valid result. */
130
+ export class Right<L, R> extends Coproduct<L, R> {
131
+ constructor(private readonly value: R) {
132
+ super()
133
+ }
134
+
135
+ isLeft(): this is Left<L, R> {
136
+ return false
137
+ }
138
+
139
+ isRight(): this is Right<L, R> {
140
+ return true
141
+ }
142
+
143
+ map<T>(fn: (r: R) => T): Coproduct<L, T> {
144
+ return new Right(fn(this.value))
145
+ }
146
+
147
+ async mapAsync<T>(fn: (r: R) => Promise<T>): Promise<Coproduct<L, T>> {
148
+ return new Right(await fn(this.value))
149
+ }
150
+
151
+ flatMap<T>(fn: (r: R) => Coproduct<L, T>): Coproduct<L, T> {
152
+ return fn(this.value)
153
+ }
154
+
155
+ mapLeft<T>(_fn: (l: L) => T): Coproduct<T, R> {
156
+ return new Right(this.value)
157
+ }
158
+
159
+ flatMapLeft<T>(_fn: (l: L) => Coproduct<T, R>): Coproduct<T, R> {
160
+ return new Right(this.value)
161
+ }
162
+
163
+ fold<T, U>(_onLeft: (l: L) => T, onRight: (r: R) => U): T | U {
164
+ return onRight(this.value)
165
+ }
166
+
167
+ tap(fn: (r: R) => void): this {
168
+ const valueToTap = this.value
169
+ fn(valueToTap)
170
+ return this
171
+ }
172
+
173
+ ltap(_fn: (l: L) => void): Coproduct<L, R> {
174
+ return this
175
+ }
176
+
177
+ drop(): L | R {
178
+ return this.value
179
+ }
180
+
181
+ recover(_defaultValue: R): R {
182
+ return this.drop() as R
183
+ }
184
+ }
@@ -0,0 +1,9 @@
1
+ import { Coproduct } from './coproduct'
2
+
3
+ /** A Coproduct under the convention that left carries the error and right carries the success value. */
4
+ export type Either<L, R> = Coproduct<L, R>
5
+
6
+ export const Either = {
7
+ left: Coproduct.left,
8
+ right: Coproduct.right,
9
+ }
@@ -0,0 +1,9 @@
1
+ export abstract class Functor<T> {
2
+ /** Applies a function to the value inside the functor */
3
+ abstract map<U>(fn: (value: T) => U): Functor<U>
4
+
5
+ /** Lifts a value into a Functor*/
6
+ static of<T>(_value: T): Functor<T> {
7
+ throw new Error('Method not implemented. Use a subclass of Functor.')
8
+ }
9
+ }
@@ -0,0 +1,4 @@
1
+ export interface Droppable<T> {
2
+ /** Drops the value out of the container and returns it. */
3
+ drop(): T
4
+ }
@@ -0,0 +1,24 @@
1
+ export interface Foldable<A> {
2
+ /**
3
+ * Folds the values in the container using the provided function.
4
+ * Similar to reduce for collections (arrays/lists/trees)
5
+ */
6
+ foldMap<T>(initial: T, fn: (acc: T, value: A) => T): T
7
+ }
8
+
9
+ /** FoldableProduct is a specialized Foldable for containers that hold two values, like a tuple. */
10
+ export interface FoldableProduct<F, S> extends Foldable<F | S> {
11
+ fold<T>(fn: (first: F, second: S) => T): T
12
+ }
13
+
14
+ /** DisjointFoldable is a specialized Foldable for containers that can hold either a single value or none, like Maybe. */
15
+ export interface DisjointFoldable1<A> extends Foldable<A> {
16
+ /** Folds the values in the container using the provided function. */
17
+ fold<T>(onNone: () => T, onSome: (a: A) => T): T
18
+ }
19
+
20
+ /** DisjointFoldable2 is a specialized Foldable for containers that can hold either a left or right value, like Either. */
21
+ export interface DisjointFoldable2<L, R> extends Foldable<L | R> {
22
+ /** Folds the values in the container using the provided functions for left and right. */
23
+ fold<T>(onLeft: (l: L) => T, onRight: (r: R) => T): T
24
+ }
@@ -0,0 +1,4 @@
1
+ export interface Recoverable<T> {
2
+ /** Recovers the value from the container, providing a default value if the container is empty or contains lefty values. */
3
+ recover(defaultValue: T): T
4
+ }
@@ -0,0 +1,4 @@
1
+ export interface Tappable<V> {
2
+ /** Visits the value inside the container and applies a function to it without modifying. */
3
+ tap(fn: (value: V) => void): this
4
+ }
@@ -0,0 +1,6 @@
1
+ import { Functor } from './functor'
2
+
3
+ export abstract class Monad<T> extends Functor<T> {
4
+ /** Applies a function to the value inside the monad, returning a new monad. */
5
+ abstract flatMap<U>(fn: (value: T) => Monad<U>): Monad<U>
6
+ }
@@ -0,0 +1,204 @@
1
+ import { Filters as f } from './filter'
2
+ import { decorateAll, type FieldDecorator } from './decorators/base'
3
+ import { accessDecorator, decorateAccess } from './decorators/need-access'
4
+ import { lifetimeUpgradeDecorator } from './decorators/need-lifetime-upgrade'
5
+ import { pageTypeDecorator } from './decorators/page-type'
6
+ import { decorateNavigateTo } from './decorators/navigate-to'
7
+ import { fetchUserPermissions, type UserPermissions } from '../../services/permissions'
8
+ import { groq } from './groq'
9
+ import { composite, query } from './query'
10
+ import { run, sanityRunner, QueryRunner, SanityQueryError } from './runner'
11
+ import { Either } from '../ads/either'
12
+
13
+ interface Song {
14
+ _id: string
15
+ title: string
16
+ railcontent_id: number
17
+ }
18
+
19
+ interface SongPage {
20
+ data: Song[]
21
+ total: number
22
+ }
23
+
24
+ const publishedSongs = (brand: string) =>
25
+ f.combine(f.type('song'), f.statusIn(['published']), f.defined('railcontent_id'), f.brand(brand))
26
+
27
+ export async function fetchSongTitles(brand: string) {
28
+ const result = await groq()
29
+ .and(publishedSongs(brand))
30
+ .order('published_on desc')
31
+ .slice(0, 10)
32
+ .select('_id', 'title', 'railcontent_id')
33
+ .run<Song[]>()
34
+
35
+ return result.fold(
36
+ (error) => {
37
+ console.error(`${error.name}: ${error.message}`, error.groq)
38
+ return []
39
+ },
40
+ (songs) => songs ?? []
41
+ )
42
+ }
43
+
44
+ export async function fetchSongPage(brand: string, offset: number, limit: number) {
45
+ const restrictions = publishedSongs(brand)
46
+
47
+ const result = await groq
48
+ .composite({
49
+ data: query()
50
+ .and(restrictions)
51
+ .order('published_on desc')
52
+ .slice(offset, limit)
53
+ .select('_id', 'title', 'railcontent_id'),
54
+ total: f.count(restrictions),
55
+ })
56
+ .run<SongPage>()
57
+
58
+ return result.recover({ data: [], total: 0 })
59
+ }
60
+
61
+ export async function countSongsAndLessons(brand: string) {
62
+ const result = await run<{ songs: number; lessons: number }>(
63
+ composite({
64
+ songs: f.count(f.combine(f.type('song'), f.brand(brand))),
65
+ lessons: f.count(f.combine(f.typeIn(['course', 'quick-tips']), f.brand(brand))),
66
+ })
67
+ )
68
+
69
+ return result
70
+ .map((counts) => counts ?? { songs: 0, lessons: 0 })
71
+ .map((counts) => ({ ...counts, total: counts.songs + counts.lessons }))
72
+ .recover({ songs: 0, lessons: 0, total: 0 })
73
+ }
74
+
75
+ export async function reportMissingSongs(brand: string) {
76
+ const result = await groq().and(publishedSongs(brand)).run<Song[]>()
77
+
78
+ if (result.isRight()) {
79
+ return result.drop()
80
+ }
81
+
82
+ return null
83
+ }
84
+
85
+ export async function fetchWithSharedClient(groqQueries: string[], runner: QueryRunner<Song[]>) {
86
+ return Promise.all(groqQueries.map((groq) => run<Song[]>(groq, runner)))
87
+ }
88
+
89
+ export function buildRunnerForTests(): QueryRunner<Song[]> {
90
+ return async () => Either.right<SanityQueryError, Song[]>([])
91
+ }
92
+
93
+ export const productionRunner = sanityRunner()
94
+
95
+ interface Lesson {
96
+ id: number
97
+ type: string
98
+ brand: string
99
+ thumbnail: string
100
+ published_on: string | null
101
+ status: string
102
+ permission_id?: number[]
103
+ children?: Lesson[]
104
+ [key: string]: unknown
105
+ }
106
+
107
+ export async function fetchLessonsWithoutPermissions(brand: string) {
108
+ const result = await groq().and(f.brand(brand)).slice(0, 10).run<Lesson[]>()
109
+
110
+ return result
111
+ .map((lessons) =>
112
+ decorateAll<Lesson>(lessons ?? [], [pageTypeDecorator as FieldDecorator<Lesson>])
113
+ )
114
+ .recover([])
115
+ }
116
+
117
+ export async function fetchDecoratedLessons(brand: string) {
118
+ const [result, permissions] = await Promise.all([
119
+ groq()
120
+ .and(f.combine(f.typeIn(['course']), f.brand(brand)))
121
+ .slice(0, 10)
122
+ .run<Lesson[]>(),
123
+ fetchUserPermissions(),
124
+ ])
125
+
126
+ return result
127
+ .map((lessons) =>
128
+ decorateAll<Lesson>(lessons ?? [], [
129
+ accessDecorator(permissions) as FieldDecorator<Lesson>,
130
+ lifetimeUpgradeDecorator(permissions) as FieldDecorator<Lesson>,
131
+ pageTypeDecorator as FieldDecorator<Lesson>,
132
+ ])
133
+ )
134
+ .recover([])
135
+ }
136
+
137
+ export async function fetchLessonsWithNavigation(brand: string) {
138
+ const [result, permissions] = await Promise.all([
139
+ groq().and(f.brand(brand)).slice(0, 10).run<Lesson[]>(),
140
+ fetchUserPermissions(),
141
+ ])
142
+
143
+ const decorated = await result
144
+ .map((lessons) =>
145
+ decorateAll<Lesson>(lessons ?? [], [accessDecorator(permissions) as FieldDecorator<Lesson>])
146
+ )
147
+ .mapAsync((lessons) => decorateNavigateTo(lessons) as Promise<Lesson[]>)
148
+
149
+ return decorated.recover([])
150
+ }
151
+
152
+ export async function fetchWithACustomDecorator(brand: string) {
153
+ const isFree: FieldDecorator<Lesson> = {
154
+ field: 'is_free',
155
+ compute: (lesson) => lesson.permission_id === undefined,
156
+ }
157
+
158
+ const result = await groq().and(f.brand(brand)).run<Lesson[]>()
159
+
160
+ return result.map((lessons) => decorateAll<Lesson>(lessons ?? [], [isFree])).recover([])
161
+ }
162
+
163
+ export async function fetchWhenPermissionsMayBeUnavailable(brand: string) {
164
+ const queryError = (error: unknown) =>
165
+ Either.left<SanityQueryError, UserPermissions>(
166
+ new SanityQueryError('Failed to fetch user permissions', '', error)
167
+ )
168
+
169
+ const [result, permissions] = await Promise.all([
170
+ groq().and(f.brand(brand)).run<Lesson[]>(),
171
+ fetchUserPermissions()
172
+ .then(Either.right<SanityQueryError, UserPermissions>)
173
+ .catch(queryError),
174
+ ])
175
+
176
+ return result
177
+ .flatMap((lessons) =>
178
+ permissions.map((userPermissions) =>
179
+ decorateAll<Lesson>(lessons ?? [], [
180
+ accessDecorator(userPermissions) as FieldDecorator<Lesson>,
181
+ ])
182
+ )
183
+ )
184
+ .fold(
185
+ (error) => `undecorated: ${error.message}`,
186
+ (lessons) => `${lessons.length} lessons`
187
+ )
188
+ }
189
+
190
+ export async function decorateManuallyForPreciseTypes(brand: string) {
191
+ const [result, permissions] = await Promise.all([
192
+ groq().and(f.brand(brand)).slice(0, 10).run<Lesson[]>(),
193
+ fetchUserPermissions(),
194
+ ])
195
+
196
+ const decorated = await result
197
+ .map((lessons) => decorateAccess(lessons ?? [], permissions))
198
+ .mapAsync((lessons) => decorateNavigateTo(lessons))
199
+
200
+ return decorated.fold(
201
+ () => [],
202
+ (lessons) => lessons.map((lesson) => [lesson.need_access, lesson.navigateTo] as const)
203
+ )
204
+ }