musora-content-services 2.185.1 → 2.186.1
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/.claude/settings.local.json +6 -1
- package/CHANGELOG.md +17 -0
- package/jest.config.js +0 -1
- package/package.json +1 -1
- package/src/index.d.ts +15 -2
- package/src/index.js +15 -2
- package/src/lib/ads/async-either.ts +148 -0
- package/src/lib/ads/coproduct.ts +36 -0
- package/src/lib/sanity/decorators/navigate-to.ts +20 -14
- package/src/lib/sanity/examples.ts +27 -31
- package/src/lib/sanity/filter.ts +62 -0
- package/src/lib/sanity/groq.ts +60 -19
- package/src/lib/sanity/runner.ts +3 -2
- package/src/lib/sanity/types/marketing.d.ts +33 -0
- package/src/services/awards/internal/award-definitions.js +4 -0
- package/src/services/content.js +181 -191
- package/src/services/contentProgress.js +19 -2
- package/src/services/endScreen/endScreen.ts +1 -1
- package/src/services/marketing/promotions.ts +178 -0
- package/src/services/my-path/placement-quiz.ts +1 -1
- package/src/services/progress/mutations.ts +28 -2
- package/src/services/recommendations.ts +307 -0
- package/src/services/sanity.js +1 -1
- package/src/services/sync/repositories/content-progress.ts +26 -5
- package/src/version-info.js +1 -1
- package/src/services/recommendations.js +0 -142
package/src/lib/sanity/filter.ts
CHANGED
|
@@ -123,6 +123,15 @@ export class Filters {
|
|
|
123
123
|
return `slug.current == "${escapeDoubleQuoted(slug)}"`
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
+
/**
|
|
127
|
+
* @param {string[]} slugs - Array of slugs to filter by
|
|
128
|
+
* @returns {string} Filter expression
|
|
129
|
+
* @example Filters.slugIn(['a', 'b']) // "slug.current in ['a','b']"
|
|
130
|
+
*/
|
|
131
|
+
static slugIn(slugs: string[]): string {
|
|
132
|
+
return `slug.current in ${arrayToStringRepresentation(slugs)}`
|
|
133
|
+
}
|
|
134
|
+
|
|
126
135
|
/**
|
|
127
136
|
* @param {number} id - The railcontent_id to filter by
|
|
128
137
|
* @returns {string} Filter expression
|
|
@@ -218,6 +227,59 @@ export class Filters {
|
|
|
218
227
|
return `published_on >= "${escapeDoubleQuoted(date)}"`
|
|
219
228
|
}
|
|
220
229
|
|
|
230
|
+
/**
|
|
231
|
+
* @param {string} field - The date field to compare
|
|
232
|
+
* @param {string} date - ISO date string
|
|
233
|
+
* @returns {string} Filter expression
|
|
234
|
+
* @example Filters.dateBefore('ends_at', '2026-01-01') // 'ends_at <= "2026-01-01"'
|
|
235
|
+
*/
|
|
236
|
+
static dateBefore(field: string, date: string): string {
|
|
237
|
+
return `${field} <= "${escapeDoubleQuoted(date)}"`
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* @param {string} field - The date field to compare
|
|
242
|
+
* @param {string} date - ISO date string
|
|
243
|
+
* @returns {string} Filter expression
|
|
244
|
+
* @example Filters.dateAfter('starts_at', '2026-01-01') // 'starts_at >= "2026-01-01"'
|
|
245
|
+
*/
|
|
246
|
+
static dateAfter(field: string, date: string): string {
|
|
247
|
+
return `${field} >= "${escapeDoubleQuoted(date)}"`
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Matches documents whose window covers the given date.
|
|
252
|
+
*
|
|
253
|
+
* @param {string} startField - The field holding the start of the window
|
|
254
|
+
* @param {string} endField - The field holding the end of the window
|
|
255
|
+
* @param {string} date - ISO date string
|
|
256
|
+
* @returns {string} Filter expression
|
|
257
|
+
* @example Filters.dateWithin('starts_at', 'ends_at', '2026-11-25')
|
|
258
|
+
*/
|
|
259
|
+
static dateWithin(startField: string, endField: string, date: string): string {
|
|
260
|
+
return Filters.combine(Filters.dateBefore(startField, date), Filters.dateAfter(endField, date))
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* @param {string} field - The boolean field to check
|
|
265
|
+
* @param {boolean} [value=true] - The value to match
|
|
266
|
+
* @returns {string} Filter expression
|
|
267
|
+
* @example Filters.flag('active') // "active == true"
|
|
268
|
+
*/
|
|
269
|
+
static flag(field: string, value: boolean = true): string {
|
|
270
|
+
return `${field} == ${value}`
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* @param {string} field - The field to compare
|
|
275
|
+
* @param {string} value - The value to match
|
|
276
|
+
* @returns {string} Filter expression
|
|
277
|
+
* @example Filters.fieldEquals('key', 'upgrade-banner') // 'key == "upgrade-banner"'
|
|
278
|
+
*/
|
|
279
|
+
static fieldEquals(field: string, value: string): string {
|
|
280
|
+
return `${field} == "${escapeDoubleQuoted(value)}"`
|
|
281
|
+
}
|
|
282
|
+
|
|
221
283
|
/**
|
|
222
284
|
* @param {string} field - The field to check
|
|
223
285
|
* @returns {string} Filter expression
|
package/src/lib/sanity/groq.ts
CHANGED
|
@@ -1,30 +1,54 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { AsyncEither } from '../ads/async-either'
|
|
2
2
|
import { FieldAccess } from './field-access'
|
|
3
|
-
import { composite, query, QueryBuilder } from './query'
|
|
3
|
+
import { composite, query, QueryBuilder, QueryBuilderState } from './query'
|
|
4
4
|
import { QueryRunner, run, SanityQueryError } from './runner'
|
|
5
5
|
|
|
6
|
+
type ElementOf<T> = T extends (infer U)[] ? U : T
|
|
7
|
+
|
|
8
|
+
type Field<T> = (keyof ElementOf<T> & string) | (string & {})
|
|
9
|
+
|
|
6
10
|
export interface RunnableQuery {
|
|
7
11
|
build(): string
|
|
8
12
|
toString(): string
|
|
9
|
-
run<T>(runner?: QueryRunner<T>):
|
|
13
|
+
run<T>(runner?: QueryRunner<T>): AsyncEither<SanityQueryError, T | null>
|
|
10
14
|
}
|
|
11
15
|
|
|
12
|
-
|
|
13
|
-
selector(selector: string):
|
|
14
|
-
and(expr: string):
|
|
15
|
-
or(...exprs: string[]):
|
|
16
|
-
order(expr: string):
|
|
17
|
-
slice(offset: number, limit?: number):
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
16
|
+
interface Chainable<T, Self> {
|
|
17
|
+
selector(selector: string): Self
|
|
18
|
+
and(expr: string): Self
|
|
19
|
+
or(...exprs: string[]): Self
|
|
20
|
+
order(expr: string): Self
|
|
21
|
+
slice(offset: number, limit?: number): Self
|
|
22
|
+
select(...fields: Field<T>[]): Self
|
|
23
|
+
access(field: Field<T>, fieldAccess?: FieldAccess): Self
|
|
24
|
+
dereference(): Self
|
|
25
|
+
postFilter(expr: string): Self
|
|
26
|
+
build(): string
|
|
27
|
+
toString(): string
|
|
28
|
+
|
|
29
|
+
_state(): QueryBuilderState
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* `Empty` is what `run` resolves to when the query matched nothing. {@link GroqBuilder.default}
|
|
34
|
+
* supplies a fallback and narrows it to `never`, which is the state a builder is assumed to be in
|
|
35
|
+
* unless it says otherwise: a builder that has not been given one is a `GroqBuilder<T, null>`.
|
|
36
|
+
*/
|
|
37
|
+
export interface GroqBuilder<T = unknown, Empty = never> extends Chainable<T, GroqBuilder<T, Empty>> {
|
|
38
|
+
/**
|
|
39
|
+
* Never read. Methods are checked bivariantly, so without a property here a builder with no
|
|
40
|
+
* fallback would silently satisfy a signature promising one.
|
|
41
|
+
*/
|
|
42
|
+
readonly __empty?: Empty
|
|
43
|
+
|
|
44
|
+
first(): GroqBuilder<ElementOf<T>, Empty>
|
|
45
|
+
default(value: T): GroqBuilder<T, never>
|
|
46
|
+
run<U = T>(runner?: QueryRunner<U>): AsyncEither<SanityQueryError, U | Empty>
|
|
23
47
|
}
|
|
24
48
|
|
|
25
49
|
/**
|
|
26
50
|
* @param {string} selector
|
|
27
|
-
* @returns {GroqBuilder}
|
|
51
|
+
* @returns {GroqBuilder<T>}
|
|
28
52
|
* @example
|
|
29
53
|
* const result = await groq()
|
|
30
54
|
* .and(f.type('song'))
|
|
@@ -32,13 +56,30 @@ export interface GroqBuilder extends QueryBuilder, RunnableQuery {
|
|
|
32
56
|
* .slice(0, 10)
|
|
33
57
|
* .select('_id', 'title')
|
|
34
58
|
* .run<Song[]>()
|
|
59
|
+
* @example
|
|
60
|
+
* // naming the type up front keeps it off every call in the chain
|
|
61
|
+
* const songs = await groq<Song[]>().and(f.type('song')).default([]).run()
|
|
35
62
|
*/
|
|
36
|
-
const groqBuilder = (selector?: string): GroqBuilder => {
|
|
63
|
+
const groqBuilder = <T = unknown>(selector?: string): GroqBuilder<T, null> => {
|
|
37
64
|
const builder = query(selector)
|
|
65
|
+
let fallback: { value: unknown } | undefined
|
|
66
|
+
|
|
67
|
+
const runnable = Object.assign(builder, {
|
|
68
|
+
default: (value: unknown) => {
|
|
69
|
+
fallback = { value }
|
|
70
|
+
return runnable
|
|
71
|
+
},
|
|
72
|
+
run: <U = T>(runner?: QueryRunner<U>) => {
|
|
73
|
+
const result = run<U>(builder.build(), runner)
|
|
74
|
+
const fallbackAtRunTime = fallback
|
|
75
|
+
|
|
76
|
+
return fallbackAtRunTime
|
|
77
|
+
? result.map((value) => value ?? (fallbackAtRunTime.value as U))
|
|
78
|
+
: result
|
|
79
|
+
},
|
|
80
|
+
})
|
|
38
81
|
|
|
39
|
-
return
|
|
40
|
-
run: <T>(runner?: QueryRunner<T>) => run<T>(builder.build(), runner),
|
|
41
|
-
}) as GroqBuilder
|
|
82
|
+
return runnable as unknown as GroqBuilder<T, null>
|
|
42
83
|
}
|
|
43
84
|
|
|
44
85
|
/**
|
package/src/lib/sanity/runner.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { AsyncEither } from '../ads/async-either'
|
|
1
2
|
import { Either } from '../ads/either'
|
|
2
3
|
import { SanityClient } from '../../infrastructure/sanity/SanityClient'
|
|
3
4
|
|
|
@@ -44,7 +45,7 @@ const defaultRunner = <T>(): QueryRunner<T> =>
|
|
|
44
45
|
/**
|
|
45
46
|
* @param {string} groq
|
|
46
47
|
* @param {QueryRunner<T>} runner
|
|
47
|
-
* @returns {
|
|
48
|
+
* @returns {AsyncEither<SanityQueryError, T | null>}
|
|
48
49
|
* @example
|
|
49
50
|
* const result = await run<Song[]>(query().and(f.type('song')).build())
|
|
50
51
|
* result.fold(
|
|
@@ -61,4 +62,4 @@ const defaultRunner = <T>(): QueryRunner<T> =>
|
|
|
61
62
|
export const run = <T>(
|
|
62
63
|
groq: string,
|
|
63
64
|
runner: QueryRunner<T> = defaultRunner<T>()
|
|
64
|
-
):
|
|
65
|
+
): AsyncEither<SanityQueryError, T | null> => AsyncEither.of(runner(groq))
|
|
@@ -92,3 +92,36 @@ export type FaqDocument = {
|
|
|
92
92
|
answer?: string
|
|
93
93
|
}[]
|
|
94
94
|
}
|
|
95
|
+
|
|
96
|
+
export type PromotionalCampaignDocument = {
|
|
97
|
+
_id: string
|
|
98
|
+
_type: "promotional-campaign"
|
|
99
|
+
_createdAt: string
|
|
100
|
+
_updatedAt: string
|
|
101
|
+
_rev: string
|
|
102
|
+
/** Campaign Name */
|
|
103
|
+
name: string
|
|
104
|
+
slug: { _type: "slug"; current: string }
|
|
105
|
+
/** Starts At */
|
|
106
|
+
starts_at: string
|
|
107
|
+
/** Ends At */
|
|
108
|
+
ends_at: string
|
|
109
|
+
/** Active? */
|
|
110
|
+
active?: boolean
|
|
111
|
+
/** Placements */
|
|
112
|
+
placements: {
|
|
113
|
+
_key: string
|
|
114
|
+
/** Placement Key */
|
|
115
|
+
key: string
|
|
116
|
+
title?: string
|
|
117
|
+
body?: string
|
|
118
|
+
/** Resolved to a CDN url by the promotions query projection. */
|
|
119
|
+
dark_mode_image?: string
|
|
120
|
+
/** Resolved to a CDN url by the promotions query projection. */
|
|
121
|
+
light_mode_image?: string
|
|
122
|
+
/** CTA Label */
|
|
123
|
+
cta_label?: string
|
|
124
|
+
/** Dismissible? */
|
|
125
|
+
dismissible?: boolean
|
|
126
|
+
}[]
|
|
127
|
+
}
|