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.
- package/.agent/decisions/2026-07-17-onboarding-statuses.md +21 -0
- package/.claude/settings.local.json +2 -1
- package/CHANGELOG.md +7 -0
- package/package.json +1 -1
- package/src/contentTypeConfig.js +1 -0
- package/src/index.d.ts +2 -0
- package/src/index.js +2 -0
- package/src/services/contentProgress.js +1 -1
- package/src/services/eventsAPI.js +49 -7
- package/src/services/user/onboarding.ts +22 -0
- package/test/unit/content-progress/navigateTo.test.ts +11 -0
- package/test/unit/eventsAPI.test.ts +159 -0
|
@@ -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.
|
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
package/src/contentTypeConfig.js
CHANGED
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.
|
|
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
|
-
|
|
153
|
-
|
|
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
|
+
})
|