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.
- package/.agent/decisions/2026-08-17-password-reset-mobile-token.md +27 -0
- package/.claude/settings.local.json +71 -0
- package/CHANGELOG.md +15 -0
- package/jest.config.js +3 -2
- package/package.json +1 -1
- package/src/index.d.ts +2 -0
- package/src/index.js +2 -0
- package/src/lib/ads/coproduct.ts +184 -0
- package/src/lib/ads/either.ts +9 -0
- package/src/lib/ads/functor.ts +9 -0
- package/src/lib/ads/interfaces/droppable.ts +4 -0
- package/src/lib/ads/interfaces/foldable.ts +24 -0
- package/src/lib/ads/interfaces/recoverable.ts +4 -0
- package/src/lib/ads/interfaces/tappable.ts +4 -0
- package/src/lib/ads/monad.ts +6 -0
- package/src/lib/sanity/examples.ts +204 -0
- package/src/lib/sanity/filter.ts +18 -12
- package/src/lib/sanity/groq.ts +65 -0
- package/src/lib/sanity/query.ts +5 -0
- package/src/lib/sanity/runner.ts +64 -0
- package/src/services/user/account.ts +12 -19
- package/src/services/user/session.ts +10 -2
- package/src/version-info.js +1 -1
- package/.idea/codeStyles/Project.xml +0 -61
- package/.idea/codeStyles/codeStyleConfig.xml +0 -5
- package/.idea/dataSources/6549dc28-6466-4ea3-a66b-7750cfeae4e7/storage_v2/_src_/schema/information_schema.FNRwLQ.meta +0 -2
- package/.idea/dataSources/6549dc28-6466-4ea3-a66b-7750cfeae4e7/storage_v2/_src_/schema/mysql.osA4Bg.meta +0 -2
- package/.idea/dataSources/6549dc28-6466-4ea3-a66b-7750cfeae4e7/storage_v2/_src_/schema/performance_schema.kIw0nw.meta +0 -2
- package/.idea/dataSources/6549dc28-6466-4ea3-a66b-7750cfeae4e7/storage_v2/_src_/schema/sys.zb4BAA.meta +0 -2
- package/.idea/dataSources/6549dc28-6466-4ea3-a66b-7750cfeae4e7.xml +0 -25767
- package/.idea/dataSources/data_sources_history.xml +0 -23
- package/.idea/dataSources.local.xml +0 -19
- package/.idea/dataSources.xml +0 -12
- package/.idea/modules.xml +0 -8
- package/.idea/musora-content-services.iml +0 -8
- package/.idea/php.xml +0 -19
- package/.idea/prettier.xml +0 -6
- package/.idea/vcs.xml +0 -6
- package/.idea/workspace.xml +0 -343
- 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
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,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,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
|
+
}
|