musora-content-services 2.185.0 → 2.186.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.
@@ -0,0 +1,60 @@
1
+ ---
2
+ date: 2026-10-01
3
+ branch: chore/AGI-remove-limit-from-queries
4
+ pr: https://github.com/railroadmedia/musora-content-services/pull/1067
5
+ status: open
6
+ tags: [[chore]]
7
+ related: [[2026-07-20-query-builder-tostring]]
8
+ ---
9
+
10
+ # Optional limit in fetchArtists, fetchGenres and fetchInstructors
11
+
12
+ ## Context
13
+ `fetchArtists`, `fetchGenres` and `fetchInstructors` destructured their options with a hard-coded `limit = 20`. Callers that wanted the complete list of artists, genres or instructors had no way to express that — the only workaround was to pass an arbitrarily large number. The requirement was to make `limit` genuinely optional, with omission meaning "return everything".
14
+
15
+ ## Decision
16
+ Removed the `limit = 20` default from all three services and made the `.slice()` call conditional:
17
+
18
+ ```ts
19
+ const { sort = 'lower(name)', offset = 0, limit } = options
20
+
21
+ const data = query()
22
+ .and(type)
23
+ .order(getSortOrder(sort, brand))
24
+ .select(...)
25
+ .postFilter(postFilter)
26
+
27
+ if (limit) data.slice(offset, limit)
28
+ ```
29
+
30
+ Simply dropping the default was not sufficient and would have shipped a silent breaking change. `QueryBuilder.slice(offset, limit)` in `src/lib/sanity/query.ts` computes its expression as:
31
+
32
+ ```ts
33
+ const sliceExpr = !limit ? `[${offset}]` : `[${offset}...${offset + limit}]`
34
+ ```
35
+
36
+ A missing `limit` therefore produces `[offset]` — a single-index GROQ slice, the same branch `first()` depends on — not an unbounded one. The response shape would have flipped from `{ data: Artist[] }` to `{ data: Artist }`, and TypeScript would not have caught it because the declared return type stays `Artists`.
37
+
38
+ Skipping the `.slice()` call instead leaves `state.slice` at its monoid identity (`''`), so `build()` emits no slice expression and GROQ returns the full array.
39
+
40
+ The guard is placed after the builder chain rather than inside it. This is safe for two reasons, both already covered by `test/unit/lib/query.test.ts`: every builder method returns the same closure instance and mutates shared state (`returns same builder instance for chaining`, `builds valid query after multiple modifications`), and `build()` renders state in a fixed order regardless of call order, so the slice still lands last in the emitted query.
41
+
42
+ ## Alternatives Considered
43
+ **Fixing `slice()` itself** to treat a nullish `limit` as "no slice" is the more correct fix — `!limit` is the wrong test, since `limit: 0` also falls through to the single-index branch. Rejected for now because `first()` is implemented as `this.slice()` and relies on that exact branch, making it a wider change than this ticket warranted.
44
+
45
+ **Honouring `offset` without a `limit`** via a sentinel ceiling (`data.slice(offset, MAX_RESULTS)`). Rejected: GROQ has no open-ended slice syntax (`[5...]` is invalid), so any such value would be arbitrary, and no current caller paginates without a limit.
46
+
47
+ **Adding a `when(condition, apply)` combinator** to `QueryBuilder` to keep the chain fluent. Rejected as new public API for a single call site. An audit of the other conditional clauses in these queries showed none would adopt it: `searchMatch`, `includedFields` and `progressIds` already return `Filters.empty` when their input is absent, and the `and`/`or` monoids drop empty strings, so conditionality is handled at the filter layer rather than the builder layer.
48
+
49
+ ## Process Notes
50
+ The `f.brand(brand)` helper is the one filter that does not self-nullify, which is why four sites spell out `brand ? f.brand(brand) : f.empty` (`counts.ts:26`, `:33`, `:58` and `filter.ts:481`). Making it return `Filters.empty` on a falsy brand would remove those ternaries and match the surrounding idiom, but it was deliberately left alone: an absent brand filter does not return nothing, it returns every brand's content. The required `brand: string` signature currently blocks that mistake at compile time, and the explicit ternary keeps "all brands" a conscious decision at the call site.
51
+
52
+ The post-chain guard only works because builders are interpolated lazily. `data` is a `QueryBuilder`, not a string; the `q` template literal coerces it via the `toString()` added in [[2026-07-20-query-builder-tostring]], which runs below the guard. Had `build()` been called eagerly at the end of the chain, the conditional slice would have been dropped.
53
+
54
+ ## Consequences
55
+ - Omitting `limit` now returns the complete list instead of the first 20 rows.
56
+ - `limit: 0` previously produced a single-object response through the faulty `!limit` branch; it now returns the full list.
57
+ - An `offset` passed without a `limit` is ignored rather than approximated.
58
+ - No caller changes required. Both existing consumers pass an explicit limit: `MusoraApp/src_v2/components/screens/CollectionList.tsx` uses `limit: 50`, and `musora-platform-frontend/src/modules/agi/services/entity.service.ts` defaults `fetchAll` to 40.
59
+ - Unbounded queries are now expressible against Sanity, so a caller omitting `limit` on a large brand will fetch every matching document. Worth watching if a new consumer adopts it on a hot path.
60
+ - Left unaddressed: the `Artists`, `Genres` and `Instructors` interfaces declare `total: number`, but the query only projects `"data"`. `total` is always `undefined`, so the pagination meta in `entity.service.ts` never populates.
@@ -1,14 +1,4 @@
1
1
  {
2
- "permissions": {
3
- "allow": [
4
- "Bash(rg:*)",
5
- "Bash(npm run lint:*)",
6
- "Bash(ls:*)",
7
- "Bash(npm test *)",
8
- "Bash(npx jest *)"
9
- ],
10
- "deny": []
11
- },
12
2
  "enabledMcpjsonServers": [
13
3
  "1password",
14
4
  "atlassian",
@@ -19,17 +9,22 @@
19
9
  "hex",
20
10
  "sanity",
21
11
  "mysql",
12
+ "mysql-staging",
13
+ "mysql-local",
22
14
  "slack",
23
15
  "langfuse",
24
16
  "chrome-devtools",
17
+ "mobile",
25
18
  "railway",
26
19
  "github",
27
20
  "asana",
28
21
  "nightwatch",
29
- "mysql-staging",
30
- "mysql-local",
31
- "mobile",
32
22
  "sentry",
33
- "customerio"
23
+ "customerio",
24
+ "vercel",
25
+ "cloudflare"
26
+ ],
27
+ "disabledMcpjsonServers": [
28
+ "mysql-archive"
34
29
  ]
35
30
  }
package/CHANGELOG.md CHANGED
@@ -2,6 +2,16 @@
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.186.0](https://github.com/railroadmedia/musora-content-services/compare/v2.185.1...v2.186.0) (2026-10-16)
6
+
7
+
8
+ ### Features
9
+
10
+ * **MU2-1384:** Port recommendations to the new sanity builders ([#1058](https://github.com/railroadmedia/musora-content-services/issues/1058)) ([0e2e41f](https://github.com/railroadmedia/musora-content-services/commit/0e2e41f86e26e16af18b894ab2472d54e3008022))
11
+ * **MU2-2025:** Add promotional campaign queries and claim state ([#1081](https://github.com/railroadmedia/musora-content-services/issues/1081)) ([921ab1e](https://github.com/railroadmedia/musora-content-services/commit/921ab1effee488253413fd42146377e986571e45))
12
+
13
+ ### [2.185.1](https://github.com/railroadmedia/musora-content-services/compare/v2.185.0...v2.185.1) (2026-10-06)
14
+
5
15
  ## [2.185.0](https://github.com/railroadmedia/musora-content-services/compare/v2.184.1...v2.185.0) (2026-10-01)
6
16
 
7
17
 
package/jest.config.js CHANGED
@@ -28,7 +28,6 @@ export default {
28
28
  'src/**/*.{js,ts}',
29
29
  '!src/services/sanity.js',
30
30
  '!src/services/railcontent.js',
31
- '!src/services/recommendations.js',
32
31
  '!src/index.js',
33
32
  '!src/index.d.ts',
34
33
  '!src/services/user/account.ts',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "musora-content-services",
3
- "version": "2.185.0",
3
+ "version": "2.186.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
@@ -81,7 +81,6 @@ import {
81
81
  getNewAndUpcoming,
82
82
  getOwnedContent,
83
83
  getRecent,
84
- getRecommendedForYou,
85
84
  getScheduleContentRows,
86
85
  getTabResults
87
86
  } from './services/content.js';
@@ -217,6 +216,14 @@ import {
217
216
  fetchMarketingTestimonials
218
217
  } from './services/marketing/marketing.ts';
219
218
 
219
+ import {
220
+ claimPromotionalCampaign,
221
+ dismissPromotionalCampaign,
222
+ fetchAvailablePromotionalCampaigns,
223
+ fetchPromotionalCampaigns,
224
+ fetchUserCampaignStates
225
+ } from './services/marketing/promotions.ts';
226
+
220
227
  import {
221
228
  acceptInvite,
222
229
  createAccount,
@@ -351,10 +358,11 @@ import {
351
358
 
352
359
  import {
353
360
  fetchSimilarItems,
361
+ getRecommendedForYou,
354
362
  rankCategories,
355
363
  rankItems,
356
364
  recommendations
357
- } from './services/recommendations.js';
365
+ } from './services/recommendations.ts';
358
366
 
359
367
  import {
360
368
  getReportIssueOptions,
@@ -434,6 +442,7 @@ import {
434
442
  confirmEmailChange,
435
443
  createPendingAccount,
436
444
  deleteAccount,
445
+ maskEmail,
437
446
  numberOfActiveUsers,
438
447
  requestEmailChange,
439
448
  resetPassword,
@@ -584,6 +593,7 @@ declare module 'musora-content-services' {
584
593
  buildEntityAndTotalQuery,
585
594
  buildImageSRC,
586
595
  calculateLongestStreaks,
596
+ claimPromotionalCampaign,
587
597
  clearAllCachedData,
588
598
  clearState,
589
599
  closeComment,
@@ -624,6 +634,7 @@ declare module 'musora-content-services' {
624
634
  deleteProfilePicture,
625
635
  deleteThread,
626
636
  deleteUserActivity,
637
+ dismissPromotionalCampaign,
627
638
  doesUserHaveMembership,
628
639
  duplicatePlaylist,
629
640
  editComment,
@@ -637,6 +648,7 @@ declare module 'musora-content-services' {
637
648
  fetchArtistBySlug,
638
649
  fetchArtistLessons,
639
650
  fetchArtists,
651
+ fetchAvailablePromotionalCampaigns,
640
652
  fetchBrandsByContentIds,
641
653
  fetchByRailContentId,
642
654
  fetchByRailContentIds,
@@ -703,6 +715,7 @@ declare module 'musora-content-services' {
703
715
  fetchPost,
704
716
  fetchPosts,
705
717
  fetchPracticeGoals,
718
+ fetchPromotionalCampaigns,
706
719
  fetchPublicAnnouncement,
707
720
  fetchQuizAnswers,
708
721
  fetchRecent,
@@ -731,6 +744,7 @@ declare module 'musora-content-services' {
731
744
  fetchUninterests,
732
745
  fetchUnreadCount,
733
746
  fetchUpcomingEvents,
747
+ fetchUserCampaignStates,
734
748
  fetchUserPermissions,
735
749
  fetchUserPlaylists,
736
750
  fetchUserPracticeMeta,
@@ -851,6 +865,7 @@ declare module 'musora-content-services' {
851
865
  markNotificationAsRead,
852
866
  markNotificationAsUnread,
853
867
  markThreadAsRead,
868
+ maskEmail,
854
869
  numberOfActiveUsers,
855
870
  onProgressSaved,
856
871
  openComment,
package/src/index.js CHANGED
@@ -85,7 +85,6 @@ import {
85
85
  getNewAndUpcoming,
86
86
  getOwnedContent,
87
87
  getRecent,
88
- getRecommendedForYou,
89
88
  getScheduleContentRows,
90
89
  getTabResults
91
90
  } from './services/content.js';
@@ -221,6 +220,14 @@ import {
221
220
  fetchMarketingTestimonials
222
221
  } from './services/marketing/marketing.ts';
223
222
 
223
+ import {
224
+ claimPromotionalCampaign,
225
+ dismissPromotionalCampaign,
226
+ fetchAvailablePromotionalCampaigns,
227
+ fetchPromotionalCampaigns,
228
+ fetchUserCampaignStates
229
+ } from './services/marketing/promotions.ts';
230
+
224
231
  import {
225
232
  acceptInvite,
226
233
  createAccount,
@@ -355,10 +362,11 @@ import {
355
362
 
356
363
  import {
357
364
  fetchSimilarItems,
365
+ getRecommendedForYou,
358
366
  rankCategories,
359
367
  rankItems,
360
368
  recommendations
361
- } from './services/recommendations.js';
369
+ } from './services/recommendations.ts';
362
370
 
363
371
  import {
364
372
  getReportIssueOptions,
@@ -438,6 +446,7 @@ import {
438
446
  confirmEmailChange,
439
447
  createPendingAccount,
440
448
  deleteAccount,
449
+ maskEmail,
441
450
  numberOfActiveUsers,
442
451
  requestEmailChange,
443
452
  resetPassword,
@@ -583,6 +592,7 @@ export {
583
592
  buildEntityAndTotalQuery,
584
593
  buildImageSRC,
585
594
  calculateLongestStreaks,
595
+ claimPromotionalCampaign,
586
596
  clearAllCachedData,
587
597
  clearState,
588
598
  closeComment,
@@ -623,6 +633,7 @@ export {
623
633
  deleteProfilePicture,
624
634
  deleteThread,
625
635
  deleteUserActivity,
636
+ dismissPromotionalCampaign,
626
637
  doesUserHaveMembership,
627
638
  duplicatePlaylist,
628
639
  editComment,
@@ -636,6 +647,7 @@ export {
636
647
  fetchArtistBySlug,
637
648
  fetchArtistLessons,
638
649
  fetchArtists,
650
+ fetchAvailablePromotionalCampaigns,
639
651
  fetchBrandsByContentIds,
640
652
  fetchByRailContentId,
641
653
  fetchByRailContentIds,
@@ -702,6 +714,7 @@ export {
702
714
  fetchPost,
703
715
  fetchPosts,
704
716
  fetchPracticeGoals,
717
+ fetchPromotionalCampaigns,
705
718
  fetchPublicAnnouncement,
706
719
  fetchQuizAnswers,
707
720
  fetchRecent,
@@ -730,6 +743,7 @@ export {
730
743
  fetchUninterests,
731
744
  fetchUnreadCount,
732
745
  fetchUpcomingEvents,
746
+ fetchUserCampaignStates,
733
747
  fetchUserPermissions,
734
748
  fetchUserPlaylists,
735
749
  fetchUserPracticeMeta,
@@ -850,6 +864,7 @@ export {
850
864
  markNotificationAsRead,
851
865
  markNotificationAsUnread,
852
866
  markThreadAsRead,
867
+ maskEmail,
853
868
  numberOfActiveUsers,
854
869
  onProgressSaved,
855
870
  openComment,
@@ -0,0 +1,148 @@
1
+ import { Either } from './either'
2
+
3
+ const toError = (error: unknown, message?: string): Error =>
4
+ error instanceof Error
5
+ ? error
6
+ : new Error(message ?? String(error ?? 'AsyncEither.attempt failed'))
7
+
8
+ /**
9
+ * An {@link Either} that has not resolved yet, so a chain can keep going across asynchronous
10
+ * steps instead of dropping back into `.then()` at every one. Not a `TaskEither`: the work is
11
+ * already running when the AsyncEither is built.
12
+ * @example
13
+ * const lessons = await AsyncEither.of(run<Lesson[]>(groq))
14
+ * .map((lessons) => lessons ?? [])
15
+ * .mapAsync((lessons) => decorateNavigateTo(lessons))
16
+ * .ltap((error) => console.error(error.message))
17
+ * .recover([])
18
+ */
19
+ export class AsyncEither<L, R> implements PromiseLike<Either<L, R>> {
20
+ private constructor(private readonly promise: Promise<Either<L, R>>) {}
21
+
22
+ /** @param promise - the pending Either to lift */
23
+ static of<L, R>(promise: Promise<Either<L, R>>): AsyncEither<L, R> {
24
+ return new AsyncEither(promise)
25
+ }
26
+
27
+ static attempt<R>(fn: () => Promise<R>, message?: string): AsyncEither<Error, R>
28
+ static attempt<R, L>(
29
+ fn: () => Promise<R>,
30
+ onError: (error: unknown) => L
31
+ ): AsyncEither<L, R>
32
+ /**
33
+ * @param fn - the work to run; a rejection becomes a Left instead of propagating
34
+ * @param onError - a message used when `fn` rejects with a non-Error, or a function receiving
35
+ * the rejection untouched when the left has to carry more than a message
36
+ * @example
37
+ * AsyncEither.attempt(() => fetchStates(slugs), 'failed to fetch campaign state')
38
+ * @example
39
+ * AsyncEither.attempt(
40
+ * () => client.query(groq),
41
+ * (error) => new SanityQueryError('Sanity query failed', groq, error)
42
+ * )
43
+ */
44
+ static attempt<R, L>(
45
+ fn: () => Promise<R>,
46
+ onError?: string | ((error: unknown) => L)
47
+ ): AsyncEither<L | Error, R> {
48
+ const message = typeof onError === 'string' ? onError : undefined
49
+ const toLeft =
50
+ typeof onError === 'function' ? onError : (error: unknown) => toError(error, message)
51
+
52
+ return AsyncEither.of(
53
+ fn().then(
54
+ (value) => Either.right<L | Error, R>(value),
55
+ (error) => Either.left<L | Error, R>(toLeft(error))
56
+ )
57
+ )
58
+ }
59
+
60
+ /** @param fn - maps the right value once it resolves */
61
+ map<T>(fn: (r: R) => T): AsyncEither<L, T> {
62
+ return AsyncEither.of(this.promise.then((either) => either.map(fn)))
63
+ }
64
+
65
+ /** @param fn - maps the right value with an asynchronous function; a rejection propagates */
66
+ mapAsync<T>(fn: (r: R) => Promise<T>): AsyncEither<L, T> {
67
+ return AsyncEither.of(this.promise.then((either) => either.mapAsync(fn)))
68
+ }
69
+
70
+ /**
71
+ * @param fn - maps the right value to another AsyncEither, flattening the result; its left type
72
+ * may differ, in which case the result carries the union of both
73
+ */
74
+ flatMap<T, E = L>(fn: (r: R) => AsyncEither<E, T>): AsyncEither<L | E, T> {
75
+ return AsyncEither.of(
76
+ this.promise.then((either) =>
77
+ either.fold(
78
+ (l) => Promise.resolve(Either.left<L | E, T>(l)),
79
+ (r) => fn(r).promise as Promise<Either<L | E, T>>
80
+ )
81
+ )
82
+ )
83
+ }
84
+
85
+ /** @param fn - maps the left value once it resolves */
86
+ lmap<T>(fn: (l: L) => T): AsyncEither<T, R> {
87
+ return AsyncEither.of(this.promise.then((either) => either.lmap(fn)))
88
+ }
89
+
90
+ /** @param fn - maps the left value to another AsyncEither, flattening the result */
91
+ lflatMap<T>(fn: (l: L) => AsyncEither<T, R>): AsyncEither<T, R> {
92
+ return AsyncEither.of(
93
+ this.promise.then((either) =>
94
+ either.fold(
95
+ (l) => fn(l).promise,
96
+ (r) => Promise.resolve(Either.right<T, R>(r))
97
+ )
98
+ )
99
+ )
100
+ }
101
+
102
+ /** @param fn - visits the right value without modifying it */
103
+ tap(fn: (r: R) => void): AsyncEither<L, R> {
104
+ return AsyncEither.of(this.promise.then((either) => either.tap(fn)))
105
+ }
106
+
107
+ /** @param fn - visits the left value without modifying it */
108
+ ltap(fn: (l: L) => void): AsyncEither<L, R> {
109
+ return AsyncEither.of(this.promise.then((either) => either.ltap(fn)))
110
+ }
111
+
112
+ /**
113
+ * @param onLeft - handles the left value
114
+ * @param onRight - handles the right value
115
+ * @returns {Promise<T | U>}
116
+ */
117
+ fold<T, U>(onLeft: (l: L) => T, onRight: (r: R) => U): Promise<T | U> {
118
+ return this.promise.then((either) => either.fold(onLeft, onRight))
119
+ }
120
+
121
+ /**
122
+ * @param defaultValue - returned when the resolved Either is a Left
123
+ * @returns {Promise<R>}
124
+ */
125
+ recover(defaultValue: R): Promise<R> {
126
+ return this.promise.then((either) => either.recover(defaultValue))
127
+ }
128
+
129
+ /**
130
+ * @param error - thrown instead of the left, or used as the message of the thrown Error
131
+ * @returns {Promise<R>}
132
+ */
133
+ orFail(error?: string | Error): Promise<R> {
134
+ return this.promise.then((either) => either.orFail(error))
135
+ }
136
+
137
+ /** @returns {Promise<Either<L, R>>} */
138
+ unlift(): Promise<Either<L, R>> {
139
+ return this.promise
140
+ }
141
+
142
+ then<T = Either<L, R>, U = never>(
143
+ onfulfilled?: ((either: Either<L, R>) => T | PromiseLike<T>) | null,
144
+ onrejected?: ((reason: unknown) => U | PromiseLike<U>) | null
145
+ ): Promise<T | U> {
146
+ return this.promise.then(onfulfilled, onrejected)
147
+ }
148
+ }
@@ -84,6 +84,42 @@ export abstract class Coproduct<L, R>
84
84
  */
85
85
  abstract fold<T, U>(onLeft: (l: L) => T, onRight: (r: R) => U): T | U
86
86
 
87
+ /**
88
+ * Unwraps the right value, throwing the left instead of returning it. Use at the edge of a
89
+ * chain where a failure has to become an exception.
90
+ * @param error - thrown instead of the left, or used as the message of the thrown Error
91
+ * @throws the left value when it is an `Error`, otherwise an `Error` wrapping it
92
+ * @example
93
+ * Coproduct.right(5).orFail() // 5
94
+ * Coproduct.left(new Error('bad')).orFail() // throws Error('bad')
95
+ * Coproduct.left('bad').orFail('error during request') // throws Error('error during request')
96
+ * Coproduct.left('bad').orFail(new HttpError(502)) // throws HttpError(502)
97
+ */
98
+ orFail(error?: string | Error): R {
99
+ return this.fold(
100
+ (l) => {
101
+ throw Coproduct.toError(l, error)
102
+ },
103
+ (r) => r
104
+ )
105
+ }
106
+
107
+ private static toError<L>(value: L, error?: string | Error): Error {
108
+ if (error instanceof Error) {
109
+ return error
110
+ }
111
+
112
+ if (typeof error === 'string') {
113
+ return new Error(error)
114
+ }
115
+
116
+ if (value instanceof Error) {
117
+ return value
118
+ }
119
+
120
+ return new Error(typeof value === 'string' ? value : 'Coproduct.orFail called on a Left')
121
+ }
122
+
87
123
  /** Convenience {@link fold} that applies the same `fn` regardless of side. */
88
124
  foldMap<T>(initial: T, fn: (acc: T, value: L | R) => T): T {
89
125
  return this.fold(
@@ -4,7 +4,7 @@ import {
4
4
  CollectionParameter,
5
5
  STATE,
6
6
  } from '../../../services/sync/models/ContentProgress'
7
- import { Progress } from '@/services/progress'
7
+ import { Progress } from '../../../services/progress'
8
8
 
9
9
  export const NAVIGATE_TO_FIELD = 'navigateTo' as const
10
10
 
@@ -15,9 +15,10 @@ const NAVIGABLE_TYPES = [
15
15
  'song-tutorial',
16
16
  COLLECTION_TYPE.LEARNING_PATH,
17
17
  'skill-pack',
18
+ 'documentary',
18
19
  ] as const
19
20
 
20
- const COURSE_FLOW_TYPES = ['course', 'skill-pack', 'song-tutorial']
21
+ const COURSE_FLOW_TYPES = ['course', 'skill-pack', 'song-tutorial', 'documentary']
21
22
  const GUIDED_FLOW_TYPES = ['guided-course', COLLECTION_TYPE.LEARNING_PATH]
22
23
  const TWO_DEPTH_TYPES = ['course-collection']
23
24
 
@@ -46,11 +47,18 @@ export type WithNavigateTo<T extends NavigateToDecoratable> = T & {
46
47
  navigateTo: NavigateTo | null
47
48
  }
48
49
 
50
+ const definedChildrenOf = (content: NavigateToDecoratable): NavigateToDecoratable[] =>
51
+ (content.children ?? []).filter(Boolean)
52
+
49
53
  function buildNavigateTo(
50
- content: NavigateToDecoratable,
54
+ content: NavigateToDecoratable | null | undefined,
51
55
  child: NavigateTo | null = null,
52
56
  collection: NavigateTo['collection'] = null
53
- ): NavigateTo {
57
+ ): NavigateTo | null {
58
+ if (!content) {
59
+ return null
60
+ }
61
+
54
62
  return {
55
63
  id: content.id,
56
64
  type: content.type,
@@ -72,10 +80,10 @@ async function prefetchStates(items: NavigateToDecoratable[]): Promise<NavigateC
72
80
  for (const item of items) {
73
81
  if (!item || !NAVIGABLE_TYPES.includes(item.type as (typeof NAVIGABLE_TYPES)[number])) continue
74
82
  ids.add(item.id)
75
- for (const child of item.children ?? []) {
83
+ for (const child of definedChildrenOf(item)) {
76
84
  ids.add(child.id)
77
85
  if (TWO_DEPTH_TYPES.includes(item.type)) {
78
- for (const grandchild of child.children ?? []) {
86
+ for (const grandchild of definedChildrenOf(child)) {
79
87
  ids.add(grandchild.id)
80
88
  }
81
89
  }
@@ -92,8 +100,8 @@ async function computeNavigateTo(
92
100
  ): Promise<NavigateTo | null> {
93
101
  if (!NAVIGABLE_TYPES.includes(content.type as (typeof NAVIGABLE_TYPES)[number])) return null
94
102
 
95
- const children = content.children
96
- if (!children || children.length === 0) return null
103
+ const children = definedChildrenOf(content)
104
+ if (children.length === 0) return null
97
105
 
98
106
  const contentState = ctx?.states.get(content.id) ?? (await Progress.state(content.id))
99
107
  if (contentState !== STATE.STARTED) {
@@ -117,14 +125,12 @@ async function computeNavigateTo(
117
125
  lastInteractedStatus === STATE.STARTED
118
126
  ? lastInteractedId
119
127
  : Progress.incompleteLesson(childrenStates, content.type, lastInteractedId)
120
- const target = childrenById.get(targetId)
121
- return target ? buildNavigateTo(target) : null
128
+ return buildNavigateTo(childrenById.get(targetId))
122
129
  }
123
130
 
124
131
  if (GUIDED_FLOW_TYPES.includes(content.type)) {
125
132
  const targetId = Progress.incompleteLesson(childrenStates, content.type, lastInteractedId)
126
- const target = childrenById.get(targetId)
127
- return target ? buildNavigateTo(target) : null
133
+ return buildNavigateTo(childrenById.get(targetId))
128
134
  }
129
135
 
130
136
  if (TWO_DEPTH_TYPES.includes(content.type)) {
@@ -147,10 +153,10 @@ export function navigateToDecorator(
147
153
  }
148
154
  }
149
155
 
150
- export function decorateNavigateTo<T extends NavigateToDecoratable>(
156
+ export async function decorateNavigateTo<T extends NavigateToDecoratable>(
151
157
  items: T[]
152
158
  ): Promise<WithNavigateTo<T>[]>
153
- export function decorateNavigateTo<T extends NavigateToDecoratable>(
159
+ export async function decorateNavigateTo<T extends NavigateToDecoratable>(
154
160
  items: T
155
161
  ): Promise<WithNavigateTo<T>>
156
162
  export async function decorateNavigateTo<T extends NavigateToDecoratable>(