musora-content-services 2.184.1 → 2.185.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.
@@ -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.
@@ -0,0 +1,25 @@
1
+ {
2
+ "enabledMcpjsonServers": [
3
+ "1password",
4
+ "atlassian",
5
+ "figma",
6
+ "google-workspace",
7
+ "snowflake",
8
+ "aws",
9
+ "hex",
10
+ "sanity",
11
+ "mysql",
12
+ "mysql-staging",
13
+ "mysql-local",
14
+ "slack",
15
+ "langfuse",
16
+ "chrome-devtools",
17
+ "mobile",
18
+ "railway",
19
+ "github",
20
+ "asana",
21
+ "nightwatch",
22
+ "sentry",
23
+ "customerio"
24
+ ]
25
+ }
package/CHANGELOG.md CHANGED
@@ -2,6 +2,15 @@
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.185.1](https://github.com/railroadmedia/musora-content-services/compare/v2.185.0...v2.185.1) (2026-10-06)
6
+
7
+ ## [2.185.0](https://github.com/railroadmedia/musora-content-services/compare/v2.184.1...v2.185.0) (2026-10-01)
8
+
9
+
10
+ ### Features
11
+
12
+ * **BEHLTP-380:** preroll LP intros ([#1066](https://github.com/railroadmedia/musora-content-services/issues/1066)) ([4706a1f](https://github.com/railroadmedia/musora-content-services/commit/4706a1f19d15ddfd409a85d2d8ebb1f70e134a99))
13
+
5
14
  ### [2.184.1](https://github.com/railroadmedia/musora-content-services/compare/v2.184.0...v2.184.1) (2026-09-28)
6
15
 
7
16
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "musora-content-services",
3
- "version": "2.184.1",
3
+ "version": "2.185.1",
4
4
  "description": "A package for Musoras content services ",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -549,6 +549,7 @@ export let contentTypeConfig = {
549
549
  'learning-path-v2': {
550
550
  fields: [
551
551
  `"intro_video": intro_video->{ ${getIntroVideoFields('learning-path-v2').join(', ')} }`,
552
+ `"preroll": intro_video->{ ${getPrerollFields().join(', ')} }`,
552
553
  'total_skills',
553
554
  `"resource": ${resourcesField}`,
554
555
  ],
@@ -693,6 +694,7 @@ export let contentTypeConfig = {
693
694
  "thumbnail": thumbnail.asset->url,
694
695
  length_in_seconds,
695
696
  intro_video,
697
+ "preroll": intro_video,
696
698
  child[]->{
697
699
  ${DEFAULT_FIELDS.join(',')}
698
700
  }
@@ -728,6 +730,20 @@ export function getIntroVideoFields(type) {
728
730
  return fields
729
731
  }
730
732
 
733
+ export function getPrerollFields() {
734
+ return [
735
+ `"id": railcontent_id`,
736
+ 'title',
737
+ 'brand',
738
+ `"instructor": ${instructorField}`,
739
+ `"type": _type`,
740
+ `"description": ${descriptionField}`,
741
+ `"thumbnail": thumbnail.asset->url`,
742
+ 'length_in_seconds',
743
+ 'video',
744
+ ]
745
+ }
746
+
731
747
  export const plusMembershipPermissions = 92
732
748
 
733
749
  /**
package/src/index.d.ts CHANGED
@@ -251,6 +251,11 @@ import {
251
251
  storeQuizAnswers
252
252
  } from './services/my-path/placement-quiz.ts';
253
253
 
254
+ import {
255
+ completeMyPathIntroVideo,
256
+ completePreroll
257
+ } from './services/my-path/preroll.ts';
258
+
254
259
  import {
255
260
  deleteAllNotifications,
256
261
  deleteNotification,
@@ -429,6 +434,7 @@ import {
429
434
  confirmEmailChange,
430
435
  createPendingAccount,
431
436
  deleteAccount,
437
+ maskEmail,
432
438
  numberOfActiveUsers,
433
439
  requestEmailChange,
434
440
  resetPassword,
@@ -584,6 +590,8 @@ declare module 'musora-content-services' {
584
590
  closeComment,
585
591
  completeLearningPathIntroVideo,
586
592
  completeMethodIntroVideo,
593
+ completeMyPathIntroVideo,
594
+ completePreroll,
587
595
  confirmEmailChange,
588
596
  contentStatusCompleted,
589
597
  contentStatusCompletedMany,
@@ -844,6 +852,7 @@ declare module 'musora-content-services' {
844
852
  markNotificationAsRead,
845
853
  markNotificationAsUnread,
846
854
  markThreadAsRead,
855
+ maskEmail,
847
856
  numberOfActiveUsers,
848
857
  onProgressSaved,
849
858
  openComment,
package/src/index.js CHANGED
@@ -255,6 +255,11 @@ import {
255
255
  storeQuizAnswers
256
256
  } from './services/my-path/placement-quiz.ts';
257
257
 
258
+ import {
259
+ completeMyPathIntroVideo,
260
+ completePreroll
261
+ } from './services/my-path/preroll.ts';
262
+
258
263
  import {
259
264
  deleteAllNotifications,
260
265
  deleteNotification,
@@ -433,6 +438,7 @@ import {
433
438
  confirmEmailChange,
434
439
  createPendingAccount,
435
440
  deleteAccount,
441
+ maskEmail,
436
442
  numberOfActiveUsers,
437
443
  requestEmailChange,
438
444
  resetPassword,
@@ -583,6 +589,8 @@ export {
583
589
  closeComment,
584
590
  completeLearningPathIntroVideo,
585
591
  completeMethodIntroVideo,
592
+ completeMyPathIntroVideo,
593
+ completePreroll,
586
594
  confirmEmailChange,
587
595
  contentStatusCompleted,
588
596
  contentStatusCompletedMany,
@@ -843,6 +851,7 @@ export {
843
851
  markNotificationAsRead,
844
852
  markNotificationAsUnread,
845
853
  markThreadAsRead,
854
+ maskEmail,
846
855
  numberOfActiveUsers,
847
856
  onProgressSaved,
848
857
  openComment,
@@ -37,12 +37,11 @@ export async function fetchArtists(
37
37
  ): Promise<Artists> {
38
38
  const type = f.type('artist')
39
39
  const postFilter = `lesson_count > 0`
40
- const { sort = 'lower(name)', offset = 0, limit = 20 } = options
40
+ const { sort = 'lower(name)', offset = 0, limit } = options
41
41
 
42
42
  const data = query()
43
43
  .and(type)
44
44
  .order(getSortOrder(sort, brand))
45
- .slice(offset, limit)
46
45
  .select(
47
46
  'name',
48
47
  `"slug": slug.current`,
@@ -51,6 +50,8 @@ export async function fetchArtists(
51
50
  )
52
51
  .postFilter(postFilter)
53
52
 
53
+ if (limit) data.slice(offset, limit)
54
+
54
55
  const q = `{
55
56
  "data": ${data},
56
57
  }`
@@ -140,7 +141,7 @@ export async function fetchArtistLessons(
140
141
  f.notDeprecated(),
141
142
  f.referencesIDWithFilter(f.combine(f.type('artist'), f.slug(slug))),
142
143
  f.brand(brand),
143
- f.permissions({showMembershipRestrictedContent: true}),
144
+ f.permissions({ showMembershipRestrictedContent: true }),
144
145
  f.searchMatch('title', searchTerm),
145
146
  f.includedFields(includedFields),
146
147
  f.progressIds(progressIds)
@@ -37,12 +37,11 @@ export async function fetchGenres(
37
37
  ): Promise<Genres> {
38
38
  const type = f.type('genre')
39
39
  const postFilter = `lesson_count > 0`
40
- const { sort = 'lower(name)', offset = 0, limit = 20 } = options
40
+ const { sort = 'lower(name)', offset = 0, limit } = options
41
41
 
42
42
  const data = query()
43
43
  .and(type)
44
44
  .order(getSortOrder(sort, brand))
45
- .slice(offset, limit)
46
45
  .select(
47
46
  'name',
48
47
  `"slug": slug.current`,
@@ -51,6 +50,8 @@ export async function fetchGenres(
51
50
  )
52
51
  .postFilter(postFilter)
53
52
 
53
+ if (limit) data.slice(offset, limit)
54
+
54
55
  const q = `{
55
56
  "data": ${data},
56
57
  }`
@@ -139,7 +140,7 @@ export async function fetchGenreLessons(
139
140
  f.notDeprecated(),
140
141
  f.referencesIDWithFilter(f.combine(f.type('genre'), f.slug(slug))),
141
142
  f.brand(brand),
142
- f.permissions({showMembershipRestrictedContent: true}),
143
+ f.permissions({ showMembershipRestrictedContent: true }),
143
144
  f.searchMatch('title', searchTerm),
144
145
  f.includedFields(includedFields),
145
146
  f.progressIds(progressIds)
@@ -38,12 +38,11 @@ export async function fetchInstructors(
38
38
  ): Promise<Instructors> {
39
39
  const type = f.type('instructor')
40
40
  const postFilter = `lesson_count > 0`
41
- const { sort = 'lower(name)', offset = 0, limit = 20 } = options
41
+ const { sort = 'lower(name)', offset = 0, limit } = options
42
42
 
43
43
  const data = query()
44
44
  .and(type)
45
45
  .order(getSortOrder(sort, brand))
46
- .slice(offset, limit)
47
46
  .select(
48
47
  'name',
49
48
  `"slug": slug.current`,
@@ -52,6 +51,8 @@ export async function fetchInstructors(
52
51
  )
53
52
  .postFilter(postFilter)
54
53
 
54
+ if (limit) data.slice(offset, limit)
55
+
55
56
  const q = `{
56
57
  "data": ${data},
57
58
  }`
@@ -129,7 +130,7 @@ export async function fetchInstructorLessons(
129
130
  offset = 0,
130
131
  limit = 20,
131
132
  includedFields = [],
132
- contentType = null
133
+ contentType = null,
133
134
  }: InstructorLessonsOptions = {}
134
135
  ): Promise<InstructorLessons> {
135
136
  sort = getSortOrder(sort, brand)
@@ -140,7 +141,7 @@ export async function fetchInstructorLessons(
140
141
  f.notDeprecated(),
141
142
  f.referencesIDWithFilter(f.combine(f.type('instructor'), f.slug(slug))),
142
143
  f.brand(brand),
143
- f.permissions({showMembershipRestrictedContent: true}),
144
+ f.permissions({ showMembershipRestrictedContent: true }),
144
145
  f.searchMatch('title', searchTerm),
145
146
  f.includedFields(includedFields)
146
147
  )
@@ -127,12 +127,12 @@ export async function addContextToContent(dataPromise, ...dataArgs) {
127
127
  * Key behaviors:
128
128
  * 1. Enriches all learning paths in a method structure
129
129
  * 2. Auto-sets collection for learning-path-v2 items when no collection specified
130
- * 3. Enriches intro videos when dataField_includeIntroVideo is true
130
+ * 3. Enriches LP preroll when dataField_includePreroll is true
131
131
  *
132
132
  * @param dataPromise - promise or method that provides sanity data
133
133
  * @param dataArgs - Arguments to pass to the dataPromise
134
134
  * @param options - Same as addContextToContent, plus:
135
- * @param options.dataField_includeIntroVideo - If true, adds progress to intro_video field where it exists
135
+ * @param options.dataField_includePreroll - If true, adds progress to preroll (and intro_video for FF) field where it exists
136
136
  *
137
137
  * @returns {Promise<Object | false>} - Enriched data or false if no data found
138
138
  *
@@ -140,7 +140,7 @@ export async function addContextToContent(dataPromise, ...dataArgs) {
140
140
  * // Enrich method structure with all learning paths
141
141
  * const method = await addContextToMethodContent(fetchMethodV2Structure, brand, {
142
142
  * dataField: 'learningPaths',
143
- * dataField_includeIntroVideo: true,
143
+ * dataField_includePreroll: true,
144
144
  * addProgressStatus: true,
145
145
  * addProgressPercentage: true,
146
146
  * })
@@ -151,7 +151,7 @@ export async function addContextToContent(dataPromise, ...dataArgs) {
151
151
  * collection: { id: lpId, type: 'learning-path-v2' },
152
152
  * dataField: 'children',
153
153
  * dataField_includeParent: true,
154
- * dataField_includeIntroVideo: true,
154
+ * dataField_includePreroll: true,
155
155
  * addProgressStatus: true,
156
156
  * })
157
157
  */
@@ -163,7 +163,7 @@ export async function addContextToLearningPaths(dataPromise, ...dataArgs) {
163
163
  const {
164
164
  dataField = null,
165
165
  dataField_includeParent = false,
166
- dataField_includeIntroVideo = false,
166
+ dataField_includePreroll = false,
167
167
  addProgressPercentage = false,
168
168
  addProgressStatus = false,
169
169
  addProgressTimestamp = false,
@@ -175,8 +175,9 @@ export async function addContextToLearningPaths(dataPromise, ...dataArgs) {
175
175
  } = options
176
176
 
177
177
  let dataFields = dataField ? [dataField] : []
178
- if (dataField_includeIntroVideo) {
179
- dataFields.push('intro_video')
178
+ if (dataField_includePreroll) {
179
+ dataFields.push('preroll')
180
+ dataFields.push('intro_video') // todo: remove intro_video things when My Path feature flag is removed.
180
181
  }
181
182
 
182
183
  const dataParam = lastArg === options ? dataArgs.slice(0, -1) : dataArgs
@@ -187,7 +188,7 @@ export async function addContextToLearningPaths(dataPromise, ...dataArgs) {
187
188
  if (!data) return false
188
189
 
189
190
  let items, recordIds
190
- [data, items, recordIds] = addRecordIdsToData(data, dataField, isDataAnArray, dataField_includeParent, dataField_includeIntroVideo) ?? []
191
+ [data, items, recordIds] = addRecordIdsToData(data, dataField, isDataAnArray, dataField_includeParent, dataField_includePreroll) ?? []
191
192
  if (items.length === 0) return data
192
193
 
193
194
  const ids = recordIds.map(item => extractFromRecordId(item).contentId)
@@ -304,7 +305,7 @@ function extractItemsFromData(data, dataField, isParentArray, includeParent) {
304
305
  return items
305
306
  }
306
307
 
307
- function addRecordIdsToData(data, dataField, isDataAnArray, includeParent, includeIntroVideo) {
308
+ function addRecordIdsToData(data, dataField, isDataAnArray, includeParent, includePreroll) {
308
309
  let items = []
309
310
  let recordIds = []
310
311
 
@@ -317,10 +318,11 @@ function addRecordIdsToData(data, dataField, isDataAnArray, includeParent, inclu
317
318
  items.push(content)
318
319
  recordIds.push(content.record_id)
319
320
  }
320
- if (includeIntroVideo && content.intro_video?.id) {
321
- content.intro_video.record_id = generateRecordId(content.intro_video.id, null)
322
- items.push(content.intro_video)
323
- recordIds.push(content.intro_video.record_id)
321
+ if (includePreroll && content.preroll?.id) {
322
+ content.preroll.record_id = generateRecordId(content.preroll.id, null)
323
+ content.intro_video.record_id = generateRecordId(content.intro_video.id, null) // for My Path feature flag, maintaining Method functionality.
324
+ items.push(content.preroll)
325
+ recordIds.push(content.preroll.record_id)
324
326
  }
325
327
  if (dataField) {
326
328
  for (const child of content[dataField] ?? []) {
@@ -9,7 +9,7 @@ interface LearningPath {
9
9
  title: string
10
10
  difficulty?: number
11
11
  branch: Branch
12
- intro_video_id?: number // only if we have intro videos
12
+ preroll_id?: number
13
13
  published_on?: string
14
14
  // ... any other content-related fields we need
15
15
  }
@@ -18,7 +18,7 @@ import {
18
18
  contentStatusReset,
19
19
  getAllCompletedByIds,
20
20
  getIdsWhereLastAccessedFromMethod,
21
- getProgressState,
21
+ getProgressState,
22
22
  resetStatus,
23
23
  } from '../contentProgress.js'
24
24
  import { COLLECTION_ID_SELF, COLLECTION_TYPE, CollectionParameter, STATE } from '../sync/models/ContentProgress'
@@ -254,7 +254,7 @@ export async function getEnrichedLearningPath(learningPathId: number) {
254
254
  {
255
255
  dataField: 'children',
256
256
  dataField_includeParent: true,
257
- dataField_includeIntroVideo: true,
257
+ dataField_includePreroll: true,
258
258
  addProgressStatus: true,
259
259
  addProgressPercentage: true,
260
260
  addProgressTimestamp: true,
@@ -286,7 +286,7 @@ export async function getEnrichedLearningPaths(learningPathIds: number[]) {
286
286
  {
287
287
  dataField: 'children',
288
288
  dataField_includeParent: true,
289
- dataField_includeIntroVideo: true,
289
+ dataField_includePreroll: true,
290
290
  addProgressStatus: true,
291
291
  addProgressPercentage: true,
292
292
  addProgressTimestamp: true,
@@ -484,6 +484,7 @@ export async function fetchLearningPathLessons(
484
484
  *
485
485
  * @param {number[]} contentIds The array of content IDs within the learning path
486
486
  * @returns {Promise<number[]>} Array with completed content IDs
487
+ * @deprecated Only use for Method behaviour. Learning path progress import is removed in My Path. Kept for backwards compatibility for Method.
487
488
  */
488
489
  export async function fetchLearningPathProgressCheckLessons(
489
490
  contentIds: number[],
@@ -505,6 +506,7 @@ interface completeMethodIntroVideo {
505
506
  * @returns {Promise<Array>} response - The response object.
506
507
  * @returns {Promise<Object|null>} response.intro_video_response - The intro video completion response or null if already completed.
507
508
  * @returns {Promise<Object>} response.active_path_response - The set active learning path response.
509
+ * @deprecated Only use for Method behaviour. For My Path, call `completeMyPathIntroVideo` instead.
508
510
  */
509
511
  export async function completeMethodIntroVideo(
510
512
  introVideoId: number | null,
@@ -563,6 +565,7 @@ interface completeLearningPathIntroVideo {
563
565
  * @returns {Promise<void>} response.learning_path_reset_response - The reset learning path response.
564
566
  * @returns {Promise<Object[]>} response.lesson_import_response - The responses for completing each content_id within the learning path.
565
567
  * @returns {Promise<Object|null>} response.update_dailies_response - The updated daily session if it was changed.
568
+ * @deprecated Only use for Method behaviour. For My Path, use `completePreroll` from `my-path/preroll.ts`.
566
569
  */
567
570
  export async function completeLearningPathIntroVideo(
568
571
  introVideoId: number,
@@ -14,6 +14,7 @@ interface PlacementQuizAnswers {
14
14
  gear: string[]
15
15
  }
16
16
 
17
+ // todo(BEHLTP-494): return recommended_content too.
17
18
  interface PlacementQuizResponse {
18
19
  user_id: string
19
20
  brand: string
@@ -0,0 +1,35 @@
1
+ /**
2
+ * @module MyPathPreroll
3
+ */
4
+ import { contentStatusCompleted, getProgressState } from '../contentProgress.js'
5
+ import { SyncWriteDTO } from '../sync'
6
+ import { ContentProgress } from '../sync/models'
7
+ import { STATE } from '../sync/models/ContentProgress'
8
+ import { fetchQuizAnswers } from './placement-quiz'
9
+
10
+ export async function completePreroll(
11
+ prerollId: number
12
+ ): Promise<SyncWriteDTO<ContentProgress, any> | null> {
13
+ return await completeIfNotCompleted(prerollId)
14
+ }
15
+
16
+ interface completeMyPathIntroVideo {
17
+ placement_quiz: any
18
+ recommended_content: any
19
+ }
20
+
21
+ export async function completeMyPathIntroVideo(
22
+ introVideoId: number,
23
+ brand: string,
24
+ ): Promise<any> {
25
+ completeIfNotCompleted(introVideoId) // no need to wait for result
26
+
27
+ return await fetchQuizAnswers(brand)
28
+ }
29
+
30
+ async function completeIfNotCompleted(
31
+ contentId: number,
32
+ ): Promise<SyncWriteDTO<ContentProgress, any> | null> {
33
+ const status = await getProgressState(contentId)
34
+ return status !== STATE.COMPLETED ? await contentStatusCompleted(contentId) : null
35
+ }
@@ -2326,6 +2326,7 @@ export async function fetchMethodV2IntroVideo(brand) {
2326
2326
  * Fetch the structure (just ids) of the Method for a given brand.
2327
2327
  * @param brand
2328
2328
  * @returns {Promise<*|null>}
2329
+ * @deprecated Kept for backwards compatibility for Method.
2329
2330
  */
2330
2331
  export async function fetchMethodV2Structure(brand) {
2331
2332
  const _type = 'method-v2'
@@ -2338,6 +2339,7 @@ export async function fetchMethodV2Structure(brand) {
2338
2339
  status,
2339
2340
  published_on,
2340
2341
  'intro_video_id': intro_video->railcontent_id,
2342
+ 'preroll_id': intro_video->railcontent_id,
2341
2343
  'children': child[]->railcontent_id
2342
2344
  }
2343
2345
  }`
@@ -2356,6 +2358,7 @@ export async function devFetchAllLearningPathsAndIntroVideoIdsForDelete() {
2356
2358
  * Fetch the structure (just ids) of the Method of a given learning path or learning path lesson.
2357
2359
  * @param contentId
2358
2360
  * @returns {Promise<*|null>}
2361
+ * @deprecated Kept for backwards compatibility for Method.
2359
2362
  */
2360
2363
  export async function fetchMethodV2StructureFromId(contentId) {
2361
2364
  const _type = 'method-v2'
@@ -2366,6 +2369,7 @@ export async function fetchMethodV2StructureFromId(contentId) {
2366
2369
  'learning_paths': child[]->{
2367
2370
  'id': railcontent_id,
2368
2371
  'intro_video_id': intro_video->railcontent_id,
2372
+ 'preroll_id': intro_video->railcontent_id,
2369
2373
  'children': child[]->railcontent_id
2370
2374
  }
2371
2375
  }`
@@ -9,6 +9,8 @@ import { Onboarding } from './onboarding'
9
9
  import { OAuthProvider } from './session'
10
10
  import { AuthResponse } from './types'
11
11
 
12
+ const LOCAL_PART_MASK = '***'
13
+
12
14
  export interface AccountStatus {
13
15
  requires_setup: boolean
14
16
  last_login_provider?: OAuthProvider
@@ -256,3 +258,24 @@ export async function toggleStudentView(useStudentView: boolean): Promise<UserRe
256
258
  const httpClient = new HttpClient(globalConfig.baseUrl, globalConfig.sessionConfig.token)
257
259
  return httpClient.patch<UserResource>(apiUrl, { use_student_view: useStudentView })
258
260
  }
261
+
262
+ /**
263
+ * Masks the local part of an email, keeping the domain intact.
264
+ * Shows the first character of the local part unless it's 3 characters or shorter,
265
+ * in which case the whole local part is masked.
266
+ * @example
267
+ * maskEmail('abcd@gmail.com') // 'a***@gmail.com'
268
+ * maskEmail('ab@abc.com') // '***@abc.com'
269
+ */
270
+ export function maskEmail(email: string): string {
271
+ const atIndex = email.indexOf('@')
272
+ if (atIndex === -1) {
273
+ return email
274
+ }
275
+ const localPart = email.slice(0, atIndex)
276
+ const domain = email.slice(atIndex)
277
+
278
+ return localPart.length <= 3
279
+ ? `${LOCAL_PART_MASK}${domain}`
280
+ : `${localPart[0]}${LOCAL_PART_MASK}${domain}`
281
+ }
@@ -1 +1 @@
1
- export const MCS_VERSION = '2.184.1'
1
+ export const MCS_VERSION = '2.185.1'
@@ -1,61 +0,0 @@
1
- <component name="ProjectCodeStyleConfiguration">
2
- <code_scheme name="Project" version="173">
3
- <HTMLCodeStyleSettings>
4
- <option name="HTML_SPACE_INSIDE_EMPTY_TAG" value="true" />
5
- </HTMLCodeStyleSettings>
6
- <JSCodeStyleSettings version="0">
7
- <option name="USE_SEMICOLON_AFTER_STATEMENT" value="false" />
8
- <option name="FORCE_SEMICOLON_STYLE" value="true" />
9
- <option name="SPACE_BEFORE_FUNCTION_LEFT_PARENTH" value="false" />
10
- <option name="USE_DOUBLE_QUOTES" value="false" />
11
- <option name="FORCE_QUOTE_STYlE" value="true" />
12
- <option name="ENFORCE_TRAILING_COMMA" value="WhenMultiline" />
13
- <option name="SPACES_WITHIN_OBJECT_LITERAL_BRACES" value="true" />
14
- <option name="SPACES_WITHIN_IMPORTS" value="true" />
15
- </JSCodeStyleSettings>
16
- <TypeScriptCodeStyleSettings version="0">
17
- <option name="USE_SEMICOLON_AFTER_STATEMENT" value="false" />
18
- <option name="FORCE_SEMICOLON_STYLE" value="true" />
19
- <option name="SPACE_BEFORE_FUNCTION_LEFT_PARENTH" value="false" />
20
- <option name="USE_DOUBLE_QUOTES" value="false" />
21
- <option name="FORCE_QUOTE_STYlE" value="true" />
22
- <option name="ENFORCE_TRAILING_COMMA" value="WhenMultiline" />
23
- <option name="SPACES_WITHIN_OBJECT_LITERAL_BRACES" value="true" />
24
- <option name="SPACES_WITHIN_IMPORTS" value="true" />
25
- </TypeScriptCodeStyleSettings>
26
- <VueCodeStyleSettings>
27
- <option name="INTERPOLATION_NEW_LINE_AFTER_START_DELIMITER" value="false" />
28
- <option name="INTERPOLATION_NEW_LINE_BEFORE_END_DELIMITER" value="false" />
29
- </VueCodeStyleSettings>
30
- <codeStyleSettings language="HTML">
31
- <option name="SOFT_MARGINS" value="100" />
32
- <indentOptions>
33
- <option name="INDENT_SIZE" value="2" />
34
- <option name="CONTINUATION_INDENT_SIZE" value="2" />
35
- <option name="TAB_SIZE" value="2" />
36
- </indentOptions>
37
- </codeStyleSettings>
38
- <codeStyleSettings language="JavaScript">
39
- <option name="SOFT_MARGINS" value="100" />
40
- <indentOptions>
41
- <option name="INDENT_SIZE" value="2" />
42
- <option name="CONTINUATION_INDENT_SIZE" value="2" />
43
- <option name="TAB_SIZE" value="2" />
44
- </indentOptions>
45
- </codeStyleSettings>
46
- <codeStyleSettings language="TypeScript">
47
- <option name="SOFT_MARGINS" value="100" />
48
- <indentOptions>
49
- <option name="INDENT_SIZE" value="2" />
50
- <option name="CONTINUATION_INDENT_SIZE" value="2" />
51
- <option name="TAB_SIZE" value="2" />
52
- </indentOptions>
53
- </codeStyleSettings>
54
- <codeStyleSettings language="Vue">
55
- <option name="SOFT_MARGINS" value="100" />
56
- <indentOptions>
57
- <option name="CONTINUATION_INDENT_SIZE" value="2" />
58
- </indentOptions>
59
- </codeStyleSettings>
60
- </code_scheme>
61
- </component>
@@ -1,5 +0,0 @@
1
- <component name="ProjectCodeStyleConfiguration">
2
- <state>
3
- <option name="USE_PER_PROJECT_SETTINGS" value="true" />
4
- </state>
5
- </component>
package/.idea/modules.xml DELETED
@@ -1,8 +0,0 @@
1
- <?xml version="1.0" encoding="UTF-8"?>
2
- <project version="4">
3
- <component name="ProjectModuleManager">
4
- <modules>
5
- <module fileurl="file://$PROJECT_DIR$/.idea/musora-content-services.iml" filepath="$PROJECT_DIR$/.idea/musora-content-services.iml" />
6
- </modules>
7
- </component>
8
- </project>
@@ -1,8 +0,0 @@
1
- <?xml version="1.0" encoding="UTF-8"?>
2
- <module type="WEB_MODULE" version="4">
3
- <component name="NewModuleRootManager">
4
- <content url="file://$MODULE_DIR$" />
5
- <orderEntry type="inheritedJdk" />
6
- <orderEntry type="sourceFolder" forTests="false" />
7
- </component>
8
- </module>
package/.idea/php.xml DELETED
@@ -1,19 +0,0 @@
1
- <?xml version="1.0" encoding="UTF-8"?>
2
- <project version="4">
3
- <component name="MessDetectorOptionsConfiguration">
4
- <option name="transferred" value="true" />
5
- </component>
6
- <component name="PHPCSFixerOptionsConfiguration">
7
- <option name="transferred" value="true" />
8
- </component>
9
- <component name="PHPCodeSnifferOptionsConfiguration">
10
- <option name="highlightLevel" value="WARNING" />
11
- <option name="transferred" value="true" />
12
- </component>
13
- <component name="PhpStanOptionsConfiguration">
14
- <option name="transferred" value="true" />
15
- </component>
16
- <component name="PsalmOptionsConfiguration">
17
- <option name="transferred" value="true" />
18
- </component>
19
- </project>
@@ -1,6 +0,0 @@
1
- <?xml version="1.0" encoding="UTF-8"?>
2
- <project version="4">
3
- <component name="PrettierConfiguration">
4
- <option name="myConfigurationMode" value="AUTOMATIC" />
5
- </component>
6
- </project>
package/.idea/vcs.xml DELETED
@@ -1,7 +0,0 @@
1
- <?xml version="1.0" encoding="UTF-8"?>
2
- <project version="4">
3
- <component name="VcsDirectoryMappings">
4
- <mapping directory="" vcs="Git" />
5
- <mapping directory="$PROJECT_DIR$" vcs="Git" />
6
- </component>
7
- </project>
Binary file