musora-content-services 2.167.1 → 2.167.2

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,21 @@
1
+ ---
2
+ date: 2026-07-17
3
+ branch: chore/onboarding-statuses
4
+ pr: https://github.com/railroadmedia/musora-content-services/pull/1011
5
+ status: open
6
+ tags: [[feature]]
7
+ ---
8
+
9
+ # Add cross-brand onboarding status endpoint
10
+
11
+ ## Context
12
+ `userOnboardingForBrand(brand)` only returns onboarding state for one brand at a time. Callers that need to know onboarding completion across all brands (e.g. Drumeo, Pianote, Guitareo, Singeo) for a user had no single call to get that.
13
+
14
+ ## Decision
15
+ Added `getOnboardingStatus()` in `src/services/user/onboarding.ts`, hitting `GET /api/user-management-system/v1/users/{userId}/onboardings/status`. Returns `OnboardingStatus`: `has_completed_onboarding` plus a `brands: OnboardingBrandStatus[]` array (`brand`, `is_completed`, `completed_flow`, `completed_at`). Ran `npm run build-index` to regenerate `index.js`/`index.d.ts` so the new function is exported from the package.
16
+
17
+ ## Alternatives Considered
18
+ No alternatives considered — this is additive to the existing per-brand `userOnboardingForBrand` function, not a replacement.
19
+
20
+ ## Consequences
21
+ Consumers (musora-platform-frontend, MusoraApp) can now fetch all-brand onboarding status in a single request instead of looping `userOnboardingForBrand` per brand.
@@ -24,7 +24,8 @@
24
24
  "mcp__github__create_pull_request",
25
25
  "mcp__github__search_pull_requests",
26
26
  "Bash(git commit *)",
27
- "Bash(git add *)"
27
+ "Bash(git add *)",
28
+ "mcp__github__list_pull_requests"
28
29
  ]
29
30
  },
30
31
  "model": "sonnet",
package/CHANGELOG.md CHANGED
@@ -2,6 +2,13 @@
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.167.2](https://github.com/railroadmedia/musora-content-services/compare/v2.167.1...v2.167.2) (2026-07-17)
6
+
7
+
8
+ ### Bug Fixes
9
+
10
+ * **BEHLTP-210:** add non-destructive notification polling pause ([#1009](https://github.com/railroadmedia/musora-content-services/issues/1009)) ([3c0d783](https://github.com/railroadmedia/musora-content-services/commit/3c0d783ee7ae4f54c77f9d37299ba182eaa3ebb5))
11
+
5
12
  ### [2.167.1](https://github.com/railroadmedia/musora-content-services/compare/v2.167.0...v2.167.1) (2026-07-13)
6
13
 
7
14
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "musora-content-services",
3
- "version": "2.167.1",
3
+ "version": "2.167.2",
4
4
  "description": "A package for Musoras content services ",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -315,6 +315,7 @@ export const getNextLessonLessonParentTypes = [
315
315
  'song-tutorial',
316
316
  'learning-path-v2',
317
317
  'skill-pack',
318
+ 'documentary',
318
319
  ]
319
320
 
320
321
  export const progressTypesMapping = {
package/src/index.d.ts CHANGED
@@ -451,6 +451,7 @@ import {
451
451
 
452
452
  import {
453
453
  getOnboardingRecommendedContent,
454
+ getOnboardingStatus,
454
455
  initializeOnboardingFlow,
455
456
  startOnboarding,
456
457
  updateOnboarding,
@@ -707,6 +708,7 @@ declare module 'musora-content-services' {
707
708
  getNavigateToForPlaylists,
708
709
  getNewAndUpcoming,
709
710
  getOnboardingRecommendedContent,
711
+ getOnboardingStatus,
710
712
  getOwnedContent,
711
713
  getPermissionsAdapter,
712
714
  getPermissionsVersion,
package/src/index.js CHANGED
@@ -455,6 +455,7 @@ import {
455
455
 
456
456
  import {
457
457
  getOnboardingRecommendedContent,
458
+ getOnboardingStatus,
458
459
  initializeOnboardingFlow,
459
460
  startOnboarding,
460
461
  updateOnboarding,
@@ -706,6 +707,7 @@ export {
706
707
  getNavigateToForPlaylists,
707
708
  getNewAndUpcoming,
708
709
  getOnboardingRecommendedContent,
710
+ getOnboardingStatus,
709
711
  getOwnedContent,
710
712
  getPermissionsAdapter,
711
713
  getPermissionsVersion,
@@ -173,7 +173,7 @@ export async function getNavigateTo(data) {
173
173
  const lastInteracted = await getLastInteractedOf(childrenIds)
174
174
  const lastInteractedStatus = childrenStates.get(lastInteracted)
175
175
 
176
- if (['course', 'skill-pack', 'song-tutorial'].includes(content.type)) {
176
+ if (['course', 'skill-pack', 'song-tutorial', 'documentary'].includes(content.type)) {
177
177
  // todo(BEHSTP-325): remove if/else and make findIncompleteLesson able to return current lesson if `started`
178
178
  if (lastInteractedStatus === STATE_STARTED) {
179
179
  // send to last interacted
@@ -3,6 +3,7 @@ import {fetchLiveEvent} from "./sanity"
3
3
  import { DataContext, PollingStateVersionKey } from './dataContext'
4
4
 
5
5
  const pollingStateContext = new DataContext(PollingStateVersionKey, fetchLiveEventPollingState)
6
+ const NOTIFICATION_POLLING_INTERVAL_MS = 60_000
6
7
 
7
8
  /**
8
9
  * API for managing notifications and live event polling.
@@ -12,6 +13,7 @@ class EventsAPI {
12
13
  this.brand = 'drumeo'
13
14
  this.onNotificationStateUpdated = []
14
15
  this.pollingInterval = null
16
+ this.isNotificationPollingPaused = false
15
17
  this.isLiveEventPollingActive = true
16
18
  this.resumeLiveEventTimeout = null
17
19
  }
@@ -27,6 +29,7 @@ class EventsAPI {
27
29
  */
28
30
  async initialize({ brand = 'drumeo' } = {}) {
29
31
  this.brand = brand;
32
+ this.isNotificationPollingPaused = false;
30
33
  await this.setupAutoEvents();
31
34
  await this.checkNotifications();
32
35
  }
@@ -80,15 +83,56 @@ class EventsAPI {
80
83
  const ttlMs = pauseUntil - now;
81
84
  await this.pauseLiveEventCheck(ttlMs);
82
85
  }
83
- this.pollingInterval = setInterval(() => {
84
- this.checkNotifications();
85
- }, 60000);
86
+ this.startNotificationInterval();
86
87
  } catch (error) {
87
88
  console.error('EventsApi: Failed to setup auto events:', error);
88
89
  this.checkNotifications();
89
90
  }
90
91
  }
91
92
 
93
+ /**
94
+ * Starts notification polling unless it is already active or explicitly paused.
95
+ */
96
+ startNotificationInterval() {
97
+ if (this.pollingInterval || this.isNotificationPollingPaused) {
98
+ return;
99
+ }
100
+
101
+ this.pollingInterval = setInterval(() => {
102
+ this.checkNotifications();
103
+ }, NOTIFICATION_POLLING_INTERVAL_MS);
104
+ }
105
+
106
+ /**
107
+ * Stops notification polling without removing notification subscribers.
108
+ */
109
+ stopNotificationInterval() {
110
+ if (!this.pollingInterval) {
111
+ return;
112
+ }
113
+
114
+ clearInterval(this.pollingInterval);
115
+ this.pollingInterval = null;
116
+ }
117
+
118
+ /**
119
+ * Pauses notification polling without resetting initialized state or subscribers.
120
+ */
121
+ pauseNotificationPolling() {
122
+ this.isNotificationPollingPaused = true;
123
+ this.stopNotificationInterval();
124
+ }
125
+
126
+ /**
127
+ * Resumes notification polling and immediately refreshes notification state.
128
+ * @returns {Promise<void>}
129
+ */
130
+ resumeNotificationPolling() {
131
+ this.isNotificationPollingPaused = false;
132
+ this.startNotificationInterval();
133
+ return this.checkNotifications();
134
+ }
135
+
92
136
 
93
137
  /**
94
138
  * Checks for unread notifications and triggers callbacks.
@@ -149,10 +193,8 @@ class EventsAPI {
149
193
  }
150
194
 
151
195
  destroy() {
152
- if (this.pollingInterval) {
153
- clearInterval(this.pollingInterval);
154
- this.pollingInterval = null;
155
- }
196
+ this.isNotificationPollingPaused = true;
197
+ this.stopNotificationInterval();
156
198
 
157
199
  if (this.resumeLiveEventTimeout) {
158
200
  clearTimeout(this.resumeLiveEventTimeout);
@@ -117,6 +117,28 @@ export async function userOnboardingForBrand(brand: string): Promise<Onboarding>
117
117
  )
118
118
  }
119
119
 
120
+ export interface OnboardingBrandStatus {
121
+ brand: string
122
+ is_completed: boolean
123
+ completed_flow: string | null
124
+ completed_at: Date | null
125
+ }
126
+
127
+ export interface OnboardingStatus {
128
+ has_completed_onboarding: boolean
129
+ brands: OnboardingBrandStatus[]
130
+ }
131
+
132
+ /**
133
+ * Fetches the current user's onboarding completion status across all brands.
134
+ *
135
+ * @returns {Promise<OnboardingStatus>} - A promise that resolves with the cross-brand onboarding status.
136
+ * @throws {HttpError} - If the HTTP request fails.
137
+ */
138
+ export async function getOnboardingStatus(): Promise<OnboardingStatus> {
139
+ return GET(`/api/user-management-system/v1/users/${globalConfig.sessionConfig.userId}/onboardings/status`)
140
+ }
141
+
120
142
  export interface OnboardingRecommendedContent {
121
143
  id: number
122
144
  title: string
@@ -254,6 +254,17 @@ describe('getNavigateTo', () => {
254
254
  expect(result[1]).toMatchObject({ id: 101 })
255
255
  })
256
256
 
257
+ test('documentary started lastInteracted started navigates to lastInteracted child', async () => {
258
+ mockProgressRecords = [
259
+ { content_id: 1, state: 'started', progress_percent: 50, updated_at: 1000 },
260
+ { content_id: 101, state: 'started', progress_percent: 100, updated_at: 900 },
261
+ { content_id: 102, state: 'started', progress_percent: 30, updated_at: 1000 },
262
+ ]
263
+ mockLastInteracted = 101
264
+ const result = await getNavigateTo([{ id: 1, type: 'documentary', children: [child(101), child(102)] }])
265
+ expect(result[1]).toMatchObject({ id: 101 })
266
+ })
267
+
257
268
  test('course started lastInteracted completed navigates to first incomplete after lastInteracted', async () => {
258
269
  mockProgressRecords = [
259
270
  { content_id: 1, state: 'started', progress_percent: 60, updated_at: 1000 },
@@ -0,0 +1,159 @@
1
+ import eventsAPISingleton from '../../src/services/eventsAPI.js'
2
+ import { initializeTestService } from '../initializeTests.js'
3
+
4
+ const notificationsModule = require('../../src/services/user/notifications.js')
5
+ const EventsAPI = eventsAPISingleton.constructor as new () => typeof eventsAPISingleton
6
+
7
+ function deferred<T>() {
8
+ let resolve!: (value: T) => void
9
+ const promise = new Promise<T>((res) => {
10
+ resolve = res
11
+ })
12
+
13
+ return { promise, resolve }
14
+ }
15
+
16
+ describe('EventsAPI notification polling lifecycle', () => {
17
+ let api: typeof eventsAPISingleton
18
+ let fetchUnreadCountMock: jest.SpyInstance
19
+
20
+ beforeEach(() => {
21
+ initializeTestService()
22
+ jest.useFakeTimers()
23
+
24
+ api = new EventsAPI()
25
+ jest.spyOn(api, 'initPollingControl').mockResolvedValue({})
26
+ fetchUnreadCountMock = jest
27
+ .spyOn(notificationsModule, 'fetchUnreadCount')
28
+ .mockResolvedValue({ data: 0 })
29
+ })
30
+
31
+ afterEach(() => {
32
+ api.destroy()
33
+ jest.useRealTimers()
34
+ jest.restoreAllMocks()
35
+ })
36
+
37
+ test('initialize keeps the existing polling behavior', async () => {
38
+ await api.initialize({ brand: 'pianote' })
39
+
40
+ expect(fetchUnreadCountMock).toHaveBeenCalledWith({ brand: 'pianote' })
41
+ expect(api.pollingInterval).not.toBeNull()
42
+
43
+ fetchUnreadCountMock.mockClear()
44
+ await jest.advanceTimersByTimeAsync(60000)
45
+
46
+ expect(fetchUnreadCountMock).toHaveBeenCalledTimes(1)
47
+ })
48
+
49
+ test('pause stops polling without removing subscribers', async () => {
50
+ const handler = jest.fn()
51
+ api.addNotificationStateUpdatedHandler(handler)
52
+ await api.initialize()
53
+ handler.mockClear()
54
+ fetchUnreadCountMock.mockClear()
55
+
56
+ api.pauseNotificationPolling()
57
+ await jest.advanceTimersByTimeAsync(120000)
58
+
59
+ expect(api.pollingInterval).toBeNull()
60
+ expect(api.onNotificationStateUpdated).toEqual([handler])
61
+ expect(fetchUnreadCountMock).not.toHaveBeenCalled()
62
+ })
63
+
64
+ test('resume starts one interval and immediately refreshes existing subscribers', async () => {
65
+ const handler = jest.fn()
66
+ api.addNotificationStateUpdatedHandler(handler)
67
+ await api.initialize()
68
+ api.pauseNotificationPolling()
69
+ fetchUnreadCountMock.mockResolvedValue({ data: 1, liveEvent: null })
70
+ fetchUnreadCountMock.mockClear()
71
+ handler.mockClear()
72
+
73
+ await api.resumeNotificationPolling()
74
+ const resumedInterval = api.pollingInterval
75
+ await api.resumeNotificationPolling()
76
+
77
+ expect(resumedInterval).not.toBeNull()
78
+ expect(api.pollingInterval).toBe(resumedInterval)
79
+ expect(fetchUnreadCountMock).toHaveBeenCalledTimes(2)
80
+ expect(handler).toHaveBeenLastCalledWith({ unreadCount: 1, liveEvent: null })
81
+ })
82
+
83
+ test('repeated pause calls are safe', async () => {
84
+ await api.initialize()
85
+
86
+ api.pauseNotificationPolling()
87
+ api.pauseNotificationPolling()
88
+
89
+ expect(api.pollingInterval).toBeNull()
90
+ })
91
+
92
+ test('pause during asynchronous setup prevents the interval from being created', async () => {
93
+ const pollingControl = deferred<Record<string, never>>()
94
+ jest.spyOn(api, 'initPollingControl').mockReturnValue(pollingControl.promise)
95
+
96
+ const initializePromise = api.initialize()
97
+ api.pauseNotificationPolling()
98
+ pollingControl.resolve({})
99
+ await initializePromise
100
+
101
+ expect(api.pollingInterval).toBeNull()
102
+ expect(fetchUnreadCountMock).toHaveBeenCalledTimes(1)
103
+ })
104
+
105
+ test('resume during asynchronous setup creates no duplicate interval', async () => {
106
+ const pollingControl = deferred<Record<string, never>>()
107
+ jest.spyOn(api, 'initPollingControl').mockReturnValue(pollingControl.promise)
108
+
109
+ const initializePromise = api.initialize()
110
+ api.pauseNotificationPolling()
111
+ const resumePromise = api.resumeNotificationPolling()
112
+ const resumedInterval = api.pollingInterval
113
+ pollingControl.resolve({})
114
+ await Promise.all([initializePromise, resumePromise])
115
+
116
+ expect(resumedInterval).not.toBeNull()
117
+ expect(api.pollingInterval).toBe(resumedInterval)
118
+ })
119
+
120
+ test('pause and resume while the initial unread request is pending restores polling', async () => {
121
+ const initialUnreadCount = deferred<{ data: number }>()
122
+ fetchUnreadCountMock
123
+ .mockReturnValueOnce(initialUnreadCount.promise)
124
+ .mockResolvedValue({ data: 1 })
125
+
126
+ const initializePromise = api.initialize()
127
+ await Promise.resolve()
128
+ await Promise.resolve()
129
+ expect(api.pollingInterval).not.toBeNull()
130
+
131
+ api.pauseNotificationPolling()
132
+ const resumePromise = api.resumeNotificationPolling()
133
+
134
+ expect(api.pollingInterval).not.toBeNull()
135
+ initialUnreadCount.resolve({ data: 0 })
136
+ await Promise.all([initializePromise, resumePromise])
137
+
138
+ fetchUnreadCountMock.mockClear()
139
+ await jest.advanceTimersByTimeAsync(60000)
140
+ expect(fetchUnreadCountMock).toHaveBeenCalledTimes(1)
141
+ })
142
+
143
+ test('destroy remains a full teardown and initialize can start again', async () => {
144
+ api.addNotificationStateUpdatedHandler(jest.fn())
145
+ await api.initialize()
146
+
147
+ api.destroy()
148
+
149
+ expect(api.pollingInterval).toBeNull()
150
+ expect(api.onNotificationStateUpdated).toEqual([])
151
+ expect(api.isLiveEventPollingActive).toBe(false)
152
+
153
+ fetchUnreadCountMock.mockClear()
154
+ await api.initialize()
155
+
156
+ expect(api.pollingInterval).not.toBeNull()
157
+ expect(fetchUnreadCountMock).toHaveBeenCalledTimes(1)
158
+ })
159
+ })