musora-content-services 2.154.0 → 2.155.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.
- package/.claude/settings.local.json +10 -0
- package/CHANGELOG.md +9 -0
- package/jsdoc.json +1 -0
- package/package.json +1 -1
- package/src/contentTypeConfig.js +60 -43
- package/src/index.d.ts +46 -9
- package/src/index.js +46 -9
- package/src/lib/sanity/filter.ts +1 -1
- package/src/services/content-org/learning-paths.ts +34 -10
- package/src/services/content.js +36 -10
- package/src/services/contentProgress.js +150 -33
- package/src/services/offline/activities.ts +97 -0
- package/src/services/offline/practices.ts +47 -0
- package/src/services/offline/progress.ts +104 -0
- package/src/services/permissions/PermissionsAdapter.ts +3 -17
- package/src/services/permissions/PermissionsV1Adapter.ts +8 -8
- package/src/services/permissions/PermissionsV2Adapter.ts +7 -7
- package/src/services/permissions/index.ts +10 -1
- package/src/services/permissions/permissions.ts +52 -0
- package/src/services/permissions/types.d.ts +6 -0
- package/src/services/progress-row/rows/method-card.js +11 -19
- package/src/services/sanity.js +218 -119
- package/src/services/sync/repositories/base.ts +1 -1
- package/src/services/user/types.d.ts +0 -6
- package/src/services/userActivity.js +5 -31
- package/test/integration/user/permissions.test.js +1 -1
- package/test/unit/lib/filter.test.ts +4 -4
- package/test/unit/sanityQueryService.test.js +12 -2
- package/test/unit/user/permissions.test.js +191 -0
- package/tools/generate-index.cjs +9 -2
- package/src/services/user/permissions.js +0 -38
package/src/services/content.js
CHANGED
|
@@ -16,14 +16,12 @@ import {
|
|
|
16
16
|
import {TabResponseType, Tabs, capitalizeFirstLetter} from '../contentMetaData.js'
|
|
17
17
|
import {recommendations, rankCategories, rankItems} from "./recommendations";
|
|
18
18
|
import {addContextToContent} from "./contentAggregator.js";
|
|
19
|
-
import {globalConfig} from "./config";
|
|
20
19
|
import {getUserData} from "./user/management";
|
|
21
20
|
import {
|
|
22
21
|
lessonTypesMapping,
|
|
23
22
|
ownedContentTypes
|
|
24
23
|
} from "../contentTypeConfig";
|
|
25
|
-
import {getPermissionsAdapter} from
|
|
26
|
-
import {MEMBERSHIP_PERMISSIONS} from "../constants/membership-permissions.ts";
|
|
24
|
+
import { fetchUserPermissions, getPermissionsAdapter, isUserFreeTier, doesUserHaveMembership } from './permissions/index.ts'
|
|
27
25
|
|
|
28
26
|
|
|
29
27
|
export async function getLessonContentRows (brand='drumeo', pageName = 'lessons') {
|
|
@@ -93,9 +91,12 @@ export async function getTabResults(brand, pageName, tabName, {
|
|
|
93
91
|
sort = 'recommended',
|
|
94
92
|
selectedFilters = []
|
|
95
93
|
} = {}) {
|
|
96
|
-
|
|
97
94
|
if (!tabName && ['lessons', 'songs'].includes(pageName)) return { type: TabResponseType.CATALOG, data: [], meta: { filters: [], sort: {} } }
|
|
98
95
|
|
|
96
|
+
const userPermissions = await fetchUserPermissions()
|
|
97
|
+
const permissions = userPermissions.permissions || []
|
|
98
|
+
const isFreeTier = isUserFreeTier(userPermissions)
|
|
99
|
+
|
|
99
100
|
// Extract and handle 'progress' filter separately
|
|
100
101
|
const progressFilter = selectedFilters.find(f => f.startsWith('progress,')) || 'progress,all';
|
|
101
102
|
const progressValue = progressFilter.split(',')[1].toLowerCase();
|
|
@@ -131,6 +132,10 @@ export async function getTabResults(brand, pageName, tabName, {
|
|
|
131
132
|
|
|
132
133
|
recommendedContent = filterCoursesInCourseCollections(recommendedContent)
|
|
133
134
|
|
|
135
|
+
if (isFreeTier) {
|
|
136
|
+
recommendedContent = applyPermissionsPostSort(recommendedContent, permissions)
|
|
137
|
+
}
|
|
138
|
+
|
|
134
139
|
const start = (page - 1) * limit
|
|
135
140
|
const end = start + limit
|
|
136
141
|
const pagesFilledByRec = Math.floor(recommendedContent.length / limit)
|
|
@@ -143,7 +148,8 @@ export async function getTabResults(brand, pageName, tabName, {
|
|
|
143
148
|
sort: '-published_on',
|
|
144
149
|
includedFields: mergedIncludedFields,
|
|
145
150
|
progress: progressValue,
|
|
146
|
-
excludeIds: recommendedContent.map(c => c.id)
|
|
151
|
+
excludeIds: recommendedContent.map(c => c.id),
|
|
152
|
+
sortPermissions: permissions,
|
|
147
153
|
})
|
|
148
154
|
|
|
149
155
|
// Filter out duplicates and combine
|
|
@@ -163,7 +169,8 @@ export async function getTabResults(brand, pageName, tabName, {
|
|
|
163
169
|
limit,
|
|
164
170
|
sort: '-published_on',
|
|
165
171
|
includedFields: mergedIncludedFields,
|
|
166
|
-
progress: progressValue
|
|
172
|
+
progress: progressValue,
|
|
173
|
+
sortPermissions: permissions,
|
|
167
174
|
})
|
|
168
175
|
contentToDisplay = temp.entity
|
|
169
176
|
}
|
|
@@ -175,7 +182,17 @@ export async function getTabResults(brand, pageName, tabName, {
|
|
|
175
182
|
addProgressStatus: true
|
|
176
183
|
})
|
|
177
184
|
} else {
|
|
178
|
-
let temp = await fetchTabData(
|
|
185
|
+
let temp = await fetchTabData(
|
|
186
|
+
brand,
|
|
187
|
+
pageName,
|
|
188
|
+
{
|
|
189
|
+
page,
|
|
190
|
+
limit,
|
|
191
|
+
sort,
|
|
192
|
+
includedFields: mergedIncludedFields,
|
|
193
|
+
progress: progressValue,
|
|
194
|
+
sortPermissions: permissions,
|
|
195
|
+
});
|
|
179
196
|
const [ranking, contextResults] = await Promise.all([
|
|
180
197
|
sort === 'recommended' ? rankItems(brand, temp.entity.map(e => e.id)) : [],
|
|
181
198
|
addContextToContent(() => temp.entity, {
|
|
@@ -550,9 +567,7 @@ export async function getLegacyMethods(brand)
|
|
|
550
567
|
const userPermissions = userPermissionsData.permissions
|
|
551
568
|
// Users should only have access to this if they have an active membership AS WELL as the content access
|
|
552
569
|
// This is hardcoded behaviour and isn't found elsewhere
|
|
553
|
-
const hasMembership = userPermissionsData
|
|
554
|
-
|| userPermissions.includes(MEMBERSHIP_PERMISSIONS.base)
|
|
555
|
-
|| userPermissions.includes(MEMBERSHIP_PERMISSIONS.plus)
|
|
570
|
+
const hasMembership = doesUserHaveMembership(userPermissionsData)
|
|
556
571
|
const hasContentPermission = userPermissions.includes(100000000 + ids[0])
|
|
557
572
|
if (hasMembership && hasContentPermission) {
|
|
558
573
|
return Promise.all(ids.map(id => fetchCourseCollectionData(id)))
|
|
@@ -625,3 +640,14 @@ export async function getOwnedContent(brand, {
|
|
|
625
640
|
export function filterCoursesInCourseCollections(data) {
|
|
626
641
|
return data.filter(c => !(c.type === 'course' && c.parent_id))
|
|
627
642
|
}
|
|
643
|
+
|
|
644
|
+
function applyPermissionsPostSort(contentList, permissionIds) {
|
|
645
|
+
contentList.sort((a, b) => {
|
|
646
|
+
const hasAccess = (item) =>
|
|
647
|
+
item.permission_id?.includes(permissionIds)
|
|
648
|
+
|
|
649
|
+
return hasAccess(b) - hasAccess(a);
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
return contentList;
|
|
653
|
+
}
|
|
@@ -1,10 +1,17 @@
|
|
|
1
|
-
import { getHierarchy } from './sanity.js'
|
|
1
|
+
import { getHierarchy, getHierarchies } from './sanity.js'
|
|
2
2
|
import { db } from './sync'
|
|
3
3
|
import { COLLECTION_ID_SELF, COLLECTION_TYPE, STATE } from './sync/models/ContentProgress'
|
|
4
|
-
import { trackUserPractice
|
|
4
|
+
import { trackUserPractice } from './userActivity'
|
|
5
5
|
import { getNextLessonLessonParentTypes } from '../contentTypeConfig.js'
|
|
6
6
|
import { getDailySession, onContentCompletedLearningPathActions } from './content-org/learning-paths.ts'
|
|
7
7
|
|
|
8
|
+
/**
|
|
9
|
+
* Exported functions that are excluded from index generation.
|
|
10
|
+
*
|
|
11
|
+
* @type {string[]}
|
|
12
|
+
*/
|
|
13
|
+
const excludeFromGeneratedIndex = ['_recordWatchSession', 'setStartedOrCompletedStatus', 'setStartedOrCompletedStatusMany', 'resetStatus']
|
|
14
|
+
|
|
8
15
|
const STATE_STARTED = STATE.STARTED
|
|
9
16
|
const STATE_COMPLETED = STATE.COMPLETED
|
|
10
17
|
const MAX_DEPTH = 3
|
|
@@ -46,7 +53,7 @@ export async function getNavigateToForMethod(data) {
|
|
|
46
53
|
for (const content of data) {
|
|
47
54
|
if (!content) continue
|
|
48
55
|
|
|
49
|
-
const {
|
|
56
|
+
const { collection } = extractFromRecordId(content.record_id)
|
|
50
57
|
|
|
51
58
|
const findFirstIncomplete = (ids, progresses) =>
|
|
52
59
|
ids.find(id => progresses.get(id) !== STATE_COMPLETED) || ids[0]
|
|
@@ -181,6 +188,28 @@ export async function getNavigateTo(data) {
|
|
|
181
188
|
return navigateToData
|
|
182
189
|
}
|
|
183
190
|
|
|
191
|
+
function findIncompleteLesson(progressOnItems, currentContentId, contentType) {
|
|
192
|
+
const isMap = progressOnItems instanceof Map
|
|
193
|
+
const ids = isMap ? Array.from(progressOnItems.keys()) : Object.keys(progressOnItems).map(Number)
|
|
194
|
+
const getProgress = (id) => isMap ? progressOnItems.get(id) : progressOnItems[id]
|
|
195
|
+
|
|
196
|
+
if (contentType === 'guided-course' || contentType === COLLECTION_TYPE.LEARNING_PATH) {
|
|
197
|
+
return ids.find((id) => getProgress(id) !== 'completed') || ids.at(0)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const currentIndex = ids.indexOf(Number(currentContentId))
|
|
201
|
+
if (currentIndex === -1) return null
|
|
202
|
+
|
|
203
|
+
for (let i = currentIndex + 1; i < ids.length; i++) {
|
|
204
|
+
const id = ids[i]
|
|
205
|
+
if (getProgress(id) !== 'completed') {
|
|
206
|
+
return id
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return ids[0]
|
|
211
|
+
}
|
|
212
|
+
|
|
184
213
|
function buildNavigateTo(content, child = null, collection = null) {
|
|
185
214
|
if (!content) {
|
|
186
215
|
return null
|
|
@@ -405,10 +434,10 @@ async function _getAllStartedOrCompleted({
|
|
|
405
434
|
* @param {int} mediaLengthSeconds - total length of video media || live event duration if livestream
|
|
406
435
|
* @param {int} currentSeconds - seconds timestamp relative to beginning of video
|
|
407
436
|
* @param {int} secondsPlayed - seconds played in this watch session (since last pause)
|
|
408
|
-
* @param {
|
|
437
|
+
* @param {string|null} prevSession - This function records a sessionId to pass into future updates to progress on the same video
|
|
409
438
|
* @param {int|null} instrumentId - enum value of instrument id
|
|
410
439
|
* @param {int|null} categoryId - enum value of category id
|
|
411
|
-
* @param {boolean} isLivestream - determines livestream-specific progress handling
|
|
440
|
+
* @param {boolean|null} isLivestream - determines livestream-specific progress handling
|
|
412
441
|
*/
|
|
413
442
|
export async function recordWatchSession(
|
|
414
443
|
contentId,
|
|
@@ -419,6 +448,53 @@ export async function recordWatchSession(
|
|
|
419
448
|
instrumentId = null,
|
|
420
449
|
categoryId = null,
|
|
421
450
|
isLivestream = false,
|
|
451
|
+
) {
|
|
452
|
+
return _recordWatchSession(
|
|
453
|
+
contentId,
|
|
454
|
+
mediaLengthSeconds,
|
|
455
|
+
currentSeconds,
|
|
456
|
+
secondsPlayed,
|
|
457
|
+
{
|
|
458
|
+
collection,
|
|
459
|
+
prevSession,
|
|
460
|
+
instrumentId,
|
|
461
|
+
categoryId,
|
|
462
|
+
isLivestream,
|
|
463
|
+
})
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* internal function to be called by only or offline version of recordWatchSession
|
|
468
|
+
* @return {string} sessionId - provide in future calls to update progress
|
|
469
|
+
* @param {int} contentId
|
|
470
|
+
* @param {any} collection - progress collection context, null if a-la-carte
|
|
471
|
+
* @param {string} collection.type - enum value of collection type
|
|
472
|
+
* @param {int} collection.id - content_id of parent collection (e.g. learning path content_id)
|
|
473
|
+
* @param {int} mediaLengthSeconds - total length of video media || live event duration if livestream
|
|
474
|
+
* @param {int} currentSeconds - seconds timestamp relative to beginning of video
|
|
475
|
+
* @param {int} secondsPlayed - seconds played in this watch session (since last pause)
|
|
476
|
+
* @param {string|null} prevSession - This function records a sessionId to pass into future updates to progress on the same video
|
|
477
|
+
* @param {int|null} instrumentId - enum value of instrument id
|
|
478
|
+
* @param {int|null} categoryId - enum value of category id
|
|
479
|
+
* @param {boolean|null} isLivestream - determines livestream-specific progress handling
|
|
480
|
+
* @param {boolean} isOffline - whether this watch session is being recorded in offline mode, which affects how progress is tracked and pushed
|
|
481
|
+
* @param {object|null} hierarchy - response from getHierarchy, passed in to avoid redundant calls within the same session
|
|
482
|
+
* @private
|
|
483
|
+
*/
|
|
484
|
+
export async function _recordWatchSession(
|
|
485
|
+
contentId,
|
|
486
|
+
mediaLengthSeconds,
|
|
487
|
+
currentSeconds,
|
|
488
|
+
secondsPlayed,
|
|
489
|
+
{
|
|
490
|
+
collection = null,
|
|
491
|
+
prevSession = null,
|
|
492
|
+
instrumentId = null,
|
|
493
|
+
categoryId = null,
|
|
494
|
+
isLivestream = false,
|
|
495
|
+
isOffline = false,
|
|
496
|
+
hierarchy = null,
|
|
497
|
+
} = {}
|
|
422
498
|
) {
|
|
423
499
|
contentId = normalizeContentId(contentId)
|
|
424
500
|
collection = normalizeCollection(collection)
|
|
@@ -426,7 +502,7 @@ export async function recordWatchSession(
|
|
|
426
502
|
// Track practice and progress locally (no immediate push)
|
|
427
503
|
await Promise.all([
|
|
428
504
|
trackPractice(contentId, secondsPlayed, { instrumentId, categoryId }),
|
|
429
|
-
trackProgress(contentId, collection, currentSeconds, mediaLengthSeconds, isLivestream),
|
|
505
|
+
trackProgress(contentId, collection, currentSeconds, mediaLengthSeconds, isLivestream, isOffline, hierarchy),
|
|
430
506
|
])
|
|
431
507
|
}
|
|
432
508
|
|
|
@@ -439,7 +515,15 @@ async function trackPractice(contentId, secondsPlayed, details = {}) {
|
|
|
439
515
|
return trackUserPractice(contentId, secondsPlayed, details)
|
|
440
516
|
}
|
|
441
517
|
|
|
442
|
-
async function trackProgress(
|
|
518
|
+
async function trackProgress(
|
|
519
|
+
contentId,
|
|
520
|
+
collection,
|
|
521
|
+
currentSeconds,
|
|
522
|
+
mediaLengthSeconds,
|
|
523
|
+
isLivestream = false,
|
|
524
|
+
isOffline = false,
|
|
525
|
+
hierarchy = null,
|
|
526
|
+
) {
|
|
443
527
|
const progress = Math.max(1, Math.min(
|
|
444
528
|
99,
|
|
445
529
|
Math.round(((currentSeconds ?? 0) / Math.max(1, mediaLengthSeconds)) * 100)
|
|
@@ -450,25 +534,17 @@ async function trackProgress(contentId, collection, currentSeconds, mediaLengthS
|
|
|
450
534
|
// doesn't affect livestream resumeTime, but will send users to 0 seconds in VOD
|
|
451
535
|
currentSeconds = 0
|
|
452
536
|
}
|
|
453
|
-
return saveContentProgress(contentId, collection, progress, currentSeconds, { skipPush: true })
|
|
537
|
+
return saveContentProgress(contentId, collection, progress, currentSeconds, { isOffline, hierarchy, skipPush: true })
|
|
454
538
|
}
|
|
455
539
|
|
|
456
540
|
export async function contentStatusCompleted(contentId, collection = null) {
|
|
457
541
|
collection = collection ?? {id: COLLECTION_ID_SELF, type: COLLECTION_TYPE.SELF}
|
|
458
|
-
return setStartedOrCompletedStatus(
|
|
459
|
-
normalizeContentId(contentId),
|
|
460
|
-
normalizeCollection(collection),
|
|
461
|
-
true
|
|
462
|
-
)
|
|
542
|
+
return setStartedOrCompletedStatus(contentId, collection, true)
|
|
463
543
|
}
|
|
464
544
|
|
|
465
545
|
export async function contentStatusCompletedMany(contentIds, collection = null) {
|
|
466
546
|
collection = collection ?? {id: COLLECTION_ID_SELF, type: COLLECTION_TYPE.SELF}
|
|
467
|
-
return setStartedOrCompletedStatusMany(
|
|
468
|
-
normalizeContentIds(contentIds),
|
|
469
|
-
normalizeCollection(collection),
|
|
470
|
-
true
|
|
471
|
-
)
|
|
547
|
+
return setStartedOrCompletedStatusMany(contentIds, collection, true)
|
|
472
548
|
}
|
|
473
549
|
|
|
474
550
|
// skipBubbleTrickle is only for starting enrolled GC's as a hack to get them into the progress row.
|
|
@@ -486,7 +562,18 @@ export async function contentStatusReset(contentId, collection = null, {skipPush
|
|
|
486
562
|
return resetStatus(contentId, collection, {skipPush})
|
|
487
563
|
}
|
|
488
564
|
|
|
489
|
-
async function saveContentProgress(
|
|
565
|
+
async function saveContentProgress(
|
|
566
|
+
contentId,
|
|
567
|
+
collection,
|
|
568
|
+
progress,
|
|
569
|
+
currentSeconds,
|
|
570
|
+
{
|
|
571
|
+
isOffline = false,
|
|
572
|
+
hierarchy = null,
|
|
573
|
+
skipPush = false,
|
|
574
|
+
accessedDirectly = true,
|
|
575
|
+
} = {}
|
|
576
|
+
) {
|
|
490
577
|
collection = collection ?? {id: COLLECTION_ID_SELF, type: COLLECTION_TYPE.SELF}
|
|
491
578
|
const isLP = collection?.type === COLLECTION_TYPE.LEARNING_PATH
|
|
492
579
|
const isPlaylist = collection?.type === COLLECTION_TYPE.PLAYLIST
|
|
@@ -498,7 +585,9 @@ async function saveContentProgress(contentId, collection, progress, currentSecon
|
|
|
498
585
|
progress = currentProgress
|
|
499
586
|
}
|
|
500
587
|
|
|
501
|
-
|
|
588
|
+
if (!isOffline) {
|
|
589
|
+
hierarchy = await getHierarchy(contentId, collection)
|
|
590
|
+
}
|
|
502
591
|
const metadata = hierarchy.metadata || {}
|
|
503
592
|
|
|
504
593
|
if (isPlaylist) {
|
|
@@ -518,10 +607,10 @@ async function saveContentProgress(contentId, collection, progress, currentSecon
|
|
|
518
607
|
// note - previous implementation explicitly did not trickle progress to children here
|
|
519
608
|
// (only to siblings/parents via le bubbles)
|
|
520
609
|
|
|
521
|
-
// skip bubbling if progress hasnt changed
|
|
522
|
-
if (progress === currentProgress) {
|
|
610
|
+
// skip bubbling if progress hasnt changed, or if offline
|
|
611
|
+
if (progress === currentProgress || offline) {
|
|
523
612
|
if (!skipPush) db.contentProgress.requestPushUnsynced('save-content-progress')
|
|
524
|
-
return
|
|
613
|
+
return response
|
|
525
614
|
}
|
|
526
615
|
|
|
527
616
|
const bubbledProgresses = await bubbleProgress(hierarchy, contentId, collection)
|
|
@@ -542,6 +631,7 @@ async function saveContentProgress(contentId, collection, progress, currentSecon
|
|
|
542
631
|
{skipPush: true, accessedDirectly})
|
|
543
632
|
}
|
|
544
633
|
|
|
634
|
+
// there are problems if we allow downloading LPs, since we require 2 different hierarchies for this.
|
|
545
635
|
if (isLP) {
|
|
546
636
|
let exportIds = bubbledProgresses
|
|
547
637
|
exportIds[contentId] = progress
|
|
@@ -561,10 +651,12 @@ async function saveContentProgress(contentId, collection, progress, currentSecon
|
|
|
561
651
|
return response
|
|
562
652
|
}
|
|
563
653
|
|
|
564
|
-
async function setStartedOrCompletedStatus(contentId, collection, isCompleted, {skipPush = false, skipBubbleTrickle = false} = {}) {
|
|
654
|
+
async function setStartedOrCompletedStatus(contentId, collection, isCompleted, { isOffline = false, hierarchy = null, skipPush = false, skipBubbleTrickle = false } = {}) {
|
|
565
655
|
const isLP = collection?.type === COLLECTION_TYPE.LEARNING_PATH
|
|
566
656
|
|
|
567
|
-
|
|
657
|
+
if (!isOffline) {
|
|
658
|
+
hierarchy = await getHierarchy(contentId, collection)
|
|
659
|
+
}
|
|
568
660
|
const metadata = hierarchy.metadata || {}
|
|
569
661
|
|
|
570
662
|
const progress = isCompleted ? 100 : 0
|
|
@@ -576,7 +668,13 @@ async function setStartedOrCompletedStatus(contentId, collection, isCompleted, {
|
|
|
576
668
|
null,
|
|
577
669
|
{skipPush: true}
|
|
578
670
|
)
|
|
579
|
-
|
|
671
|
+
|
|
672
|
+
// skip bubbling if offline
|
|
673
|
+
if (isOffline) {
|
|
674
|
+
if (!skipPush) db.contentProgress.requestPushUnsynced('save-content-progress')
|
|
675
|
+
return response
|
|
676
|
+
}
|
|
677
|
+
|
|
580
678
|
let allProgresses = {}
|
|
581
679
|
allProgresses[contentId] = progress
|
|
582
680
|
|
|
@@ -605,12 +703,16 @@ async function setStartedOrCompletedStatus(contentId, collection, isCompleted, {
|
|
|
605
703
|
return response
|
|
606
704
|
}
|
|
607
705
|
|
|
608
|
-
async function setStartedOrCompletedStatusMany(contentIds, collection, isCompleted, {skipPush = false} = {}) {
|
|
706
|
+
export async function setStartedOrCompletedStatusMany(contentIds, collection, isCompleted, { isOffline = false, hierarchy = null, skipPush = false } = {}) {
|
|
707
|
+
contentIds = normalizeContentIds(contentIds)
|
|
708
|
+
collection = normalizeCollection(collection)
|
|
709
|
+
|
|
609
710
|
const isLP = collection?.type === COLLECTION_TYPE.LEARNING_PATH
|
|
610
711
|
const progress = isCompleted ? 100 : 0
|
|
611
712
|
|
|
612
|
-
|
|
613
|
-
|
|
713
|
+
if (!isOffline) {
|
|
714
|
+
hierarchy = await getHierarchies(contentIds, collection)
|
|
715
|
+
}
|
|
614
716
|
const metadata = hierarchy.metadata || {}
|
|
615
717
|
|
|
616
718
|
const contents = Object.fromEntries(contentIds.map((id) => [id, progress]))
|
|
@@ -621,6 +723,12 @@ async function setStartedOrCompletedStatusMany(contentIds, collection, isComplet
|
|
|
621
723
|
{skipPush: true}
|
|
622
724
|
)
|
|
623
725
|
|
|
726
|
+
// skip bubbling if offline
|
|
727
|
+
if (isOffline) {
|
|
728
|
+
if (!skipPush) db.contentProgress.requestPushUnsynced('save-content-progress')
|
|
729
|
+
return response
|
|
730
|
+
}
|
|
731
|
+
|
|
624
732
|
let allProgresses = Object.fromEntries(contentIds.map(id => [id, progress]))
|
|
625
733
|
|
|
626
734
|
let progresses = {}
|
|
@@ -650,15 +758,24 @@ async function setStartedOrCompletedStatusMany(contentIds, collection, isComplet
|
|
|
650
758
|
return response
|
|
651
759
|
}
|
|
652
760
|
|
|
653
|
-
async function resetStatus(contentId, collection = null, {skipPush = false} = {}) {
|
|
761
|
+
export async function resetStatus(contentId, collection = null, { isOffline = false, hierarchy = null, skipPush = false } = {}) {
|
|
762
|
+
contentId = normalizeContentId(contentId)
|
|
763
|
+
collection = normalizeCollection(collection)
|
|
764
|
+
|
|
654
765
|
const isLP = collection?.type === COLLECTION_TYPE.LEARNING_PATH
|
|
655
766
|
|
|
656
767
|
const progress = 0
|
|
657
768
|
const response = await db.contentProgress.eraseProgress(normalizeContentId(contentId), normalizeCollection(collection), {skipPush: true})
|
|
658
|
-
|
|
659
|
-
|
|
769
|
+
|
|
770
|
+
// skip bubbling if offline
|
|
771
|
+
if (isOffline) {
|
|
772
|
+
if (!skipPush) db.contentProgress.requestPushUnsynced('save-content-progress')
|
|
773
|
+
return response
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
hierarchy = await getHierarchy(contentId, collection)
|
|
660
777
|
const metadata = hierarchy.metadata || {}
|
|
661
|
-
|
|
778
|
+
|
|
662
779
|
let allProgresses = {}
|
|
663
780
|
allProgresses[contentId] = progress
|
|
664
781
|
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { db } from '../sync'
|
|
2
|
+
import { Q } from '@nozbe/watermelondb'
|
|
3
|
+
import { STATE } from '../sync/models/ContentProgress'
|
|
4
|
+
import { lessonRecentTypes, SONG_TYPES } from '../../contentTypeConfig.js'
|
|
5
|
+
import dayjs from 'dayjs'
|
|
6
|
+
|
|
7
|
+
interface Activity {
|
|
8
|
+
contentId: number
|
|
9
|
+
action: 'Start' | 'Complete'
|
|
10
|
+
contentType: 'lesson' | 'song'
|
|
11
|
+
date: string
|
|
12
|
+
brand: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param offlineTimestamp - Minimum `updated_at` epoch ms to include
|
|
17
|
+
* @param options.page - Page number (default 1)
|
|
18
|
+
* @param options.limit - Results per page (default 5)
|
|
19
|
+
* @param options.tabName - Restrict to `'lessons'`, `'songs'`, or both when `null`
|
|
20
|
+
* @returns `{ currentPage, totalPages, data }` where `data` is a page of `Activity` records
|
|
21
|
+
*/
|
|
22
|
+
export async function getRecentActivityOffline(
|
|
23
|
+
offlineTimestamp: number,
|
|
24
|
+
{
|
|
25
|
+
page = 1,
|
|
26
|
+
limit = 5,
|
|
27
|
+
tabName = null
|
|
28
|
+
}: {
|
|
29
|
+
page?: number,
|
|
30
|
+
limit?: number,
|
|
31
|
+
tabName?: 'lessons'|'songs'|null
|
|
32
|
+
} = {}): Promise<any> {
|
|
33
|
+
// Note: this is kind of a hack. We're really just getting RADFOP: Recent Activities Derived From Offline Progress,
|
|
34
|
+
// because setting up watermelon user activities table is extremely complicated.
|
|
35
|
+
// Note: this implementation does not persist "activities" beyond when the corresponding record is deleted. That's ok right now.
|
|
36
|
+
|
|
37
|
+
const clauses = getClauses(offlineTimestamp, tabName)
|
|
38
|
+
|
|
39
|
+
const query = await db.contentProgress.queryAll(...clauses)
|
|
40
|
+
const progress = query.data
|
|
41
|
+
|
|
42
|
+
const activities = deriveActivitiesFromProgress(progress)
|
|
43
|
+
|
|
44
|
+
const totalPages = Math.ceil(activities.length / limit)
|
|
45
|
+
const currentPage = Math.min(page, totalPages)
|
|
46
|
+
|
|
47
|
+
const sorted = activities.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
|
|
48
|
+
const data = sorted.slice((currentPage - 1) * limit, currentPage * limit)
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
currentPage,
|
|
52
|
+
totalPages,
|
|
53
|
+
data,
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function getClauses(offlineTimestamp: number, tabName: string|null) {
|
|
58
|
+
let clauses: Q.Clause[] = [
|
|
59
|
+
Q.where('updated_at', Q.gte(offlineTimestamp)),
|
|
60
|
+
Q.sortBy('created_at', 'desc'),
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
if (tabName === 'lessons') {
|
|
64
|
+
clauses.push(Q.where('content_type', Q.oneOf(lessonRecentTypes)))
|
|
65
|
+
} else if (tabName === 'songs') {
|
|
66
|
+
clauses.push(Q.where('content_type', Q.oneOf(SONG_TYPES)))
|
|
67
|
+
} else {
|
|
68
|
+
clauses.push(Q.where('content_type', Q.oneOf([...lessonRecentTypes, ...SONG_TYPES])))
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return clauses
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function deriveActivitiesFromProgress(progress: Record<any, any>) {
|
|
75
|
+
const activities: Activity[] = []
|
|
76
|
+
progress.forEach(p => {
|
|
77
|
+
const type = lessonRecentTypes.includes(p.content_type) ? 'lesson' : 'song'
|
|
78
|
+
|
|
79
|
+
activities.push({
|
|
80
|
+
contentId: p.content_id,
|
|
81
|
+
action: 'Start',
|
|
82
|
+
contentType: type,
|
|
83
|
+
date: dayjs(p.created_at).toISOString(),
|
|
84
|
+
brand: p.content_brand,
|
|
85
|
+
})
|
|
86
|
+
if (p.state === STATE.COMPLETED) {
|
|
87
|
+
activities.push({
|
|
88
|
+
contentId: p.content_id,
|
|
89
|
+
action: 'Complete',
|
|
90
|
+
contentType: type,
|
|
91
|
+
date: dayjs(p.updated_at).toISOString(),
|
|
92
|
+
brand: p.content_brand,
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
})
|
|
96
|
+
return activities
|
|
97
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { db } from '../sync'
|
|
2
|
+
import { Q } from '@nozbe/watermelondb'
|
|
3
|
+
import dayjs from 'dayjs'
|
|
4
|
+
import { globalConfig } from '../config'
|
|
5
|
+
import { calculateLongestStreaks } from '../userActivity.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @param offlineTimestamp - Minimum `updated_at` epoch ms to include
|
|
9
|
+
* @param options.day - Date in YYYY-MM-DD format, defaults to today
|
|
10
|
+
* @returns `{ data: { practices, practiceDuration } }` where `practiceDuration` is total seconds
|
|
11
|
+
*/
|
|
12
|
+
export async function getPracticeSessionsOffline(
|
|
13
|
+
offlineTimestamp: number,
|
|
14
|
+
{ day = dayjs().format('YYYY-MM-DD') }: { day?: string } = {}
|
|
15
|
+
) {
|
|
16
|
+
|
|
17
|
+
const query = await db.practices.queryAll(
|
|
18
|
+
Q.where('updated_at', Q.gte(offlineTimestamp)),
|
|
19
|
+
Q.where('date', day),
|
|
20
|
+
Q.sortBy('created_at', 'asc'))
|
|
21
|
+
const practices = query.data
|
|
22
|
+
|
|
23
|
+
if (!practices.length) return { data: { practices: [], practiceDuration: 0 } }
|
|
24
|
+
|
|
25
|
+
const practiceDuration = Math.round(practices.reduce(
|
|
26
|
+
(total, practice) => total + (practice.duration_seconds || 0),
|
|
27
|
+
0
|
|
28
|
+
))
|
|
29
|
+
|
|
30
|
+
return { data: { practices, practiceDuration } }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function otherStatsOffline(userId = globalConfig.sessionConfig.userId) {
|
|
34
|
+
const longestStreaks = await calculateLongestStreaks(userId)
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
longest_day_streak: {
|
|
38
|
+
type: 'day',
|
|
39
|
+
length: longestStreaks.longestDailyStreak,
|
|
40
|
+
},
|
|
41
|
+
longest_week_streak: {
|
|
42
|
+
type: 'week',
|
|
43
|
+
length: longestStreaks.longestWeeklyStreak,
|
|
44
|
+
},
|
|
45
|
+
total_practice_time: longestStreaks.totalPracticeSeconds,
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import {
|
|
2
|
+
_recordWatchSession, resetStatus,
|
|
3
|
+
setStartedOrCompletedStatus,
|
|
4
|
+
setStartedOrCompletedStatusMany,
|
|
5
|
+
} from '../contentProgress.js'
|
|
6
|
+
import { COLLECTION_ID_SELF, COLLECTION_TYPE, CollectionParameter, STATE } from '../sync/models/ContentProgress'
|
|
7
|
+
|
|
8
|
+
interface HierarchyParameter {
|
|
9
|
+
topLevelId: number
|
|
10
|
+
parents: { [contentId: number]: [parentId: number] }
|
|
11
|
+
children: { [contentId: number]: [childId: number] }
|
|
12
|
+
metadata: {
|
|
13
|
+
brand: string
|
|
14
|
+
parent_id: number
|
|
15
|
+
type: string
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param contentId
|
|
21
|
+
* @param mediaLengthSeconds - Total length of the media
|
|
22
|
+
* @param currentSeconds - Playhead position at session end
|
|
23
|
+
* @param secondsPlayed - Seconds actively watched in this session
|
|
24
|
+
* @param hierarchy - Content hierarchy used to update parent progress offline
|
|
25
|
+
* @param options.collection - Collection context; defaults to self
|
|
26
|
+
* @param options.prevSession - Previous session identifier for continuity
|
|
27
|
+
* @param options.instrumentId - Instrument filter for the session
|
|
28
|
+
* @param options.categoryId - Category filter for the session
|
|
29
|
+
*/
|
|
30
|
+
export async function recordWatchSessionOffline(
|
|
31
|
+
contentId: number,
|
|
32
|
+
mediaLengthSeconds: number,
|
|
33
|
+
currentSeconds: number,
|
|
34
|
+
secondsPlayed: number,
|
|
35
|
+
hierarchy: HierarchyParameter,
|
|
36
|
+
{
|
|
37
|
+
collection = null,
|
|
38
|
+
prevSession = null,
|
|
39
|
+
instrumentId = null,
|
|
40
|
+
categoryId = null,
|
|
41
|
+
}: {
|
|
42
|
+
collection?: CollectionParameter|null,
|
|
43
|
+
prevSession?: string|null,
|
|
44
|
+
instrumentId?: number|null,
|
|
45
|
+
categoryId?: number|null
|
|
46
|
+
} = {}
|
|
47
|
+
) {
|
|
48
|
+
return _recordWatchSession(
|
|
49
|
+
contentId,
|
|
50
|
+
mediaLengthSeconds,
|
|
51
|
+
currentSeconds,
|
|
52
|
+
secondsPlayed,
|
|
53
|
+
{
|
|
54
|
+
collection,
|
|
55
|
+
prevSession,
|
|
56
|
+
instrumentId,
|
|
57
|
+
categoryId,
|
|
58
|
+
isOffline: true,
|
|
59
|
+
hierarchy,
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* @param contentId
|
|
65
|
+
* @param collection - Collection context; defaults to self
|
|
66
|
+
* @param hierarchy - Content hierarchy used to update parent progress offline
|
|
67
|
+
*/
|
|
68
|
+
export async function contentStatusCompletedOffline(contentId: number, collection: CollectionParameter = null, hierarchy: HierarchyParameter) {
|
|
69
|
+
collection = collection ?? {id: COLLECTION_ID_SELF, type: COLLECTION_TYPE.SELF}
|
|
70
|
+
return setStartedOrCompletedStatus(contentId, collection, true, {isOffline: true, hierarchy})
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* @param contentIds
|
|
75
|
+
* @param collection - Collection context; defaults to self
|
|
76
|
+
* @param hierarchy - Content hierarchy used to update parent progress offline
|
|
77
|
+
*/
|
|
78
|
+
export async function contentStatusCompletedManyOffline(contentIds: number[], collection: CollectionParameter = null, hierarchy: HierarchyParameter) {
|
|
79
|
+
collection = collection ?? {id: COLLECTION_ID_SELF, type: COLLECTION_TYPE.SELF}
|
|
80
|
+
return setStartedOrCompletedStatusMany(contentIds, collection, true, {isOffline: true, hierarchy})
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* @param contentId
|
|
85
|
+
* @param collection - Collection context; defaults to self
|
|
86
|
+
* @param hierarchy - Content hierarchy used to update parent progress offline
|
|
87
|
+
*/
|
|
88
|
+
export async function contentStatusStartedOffline(contentId: number, collection: CollectionParameter = null, hierarchy: HierarchyParameter) {
|
|
89
|
+
collection = collection ?? {id: COLLECTION_ID_SELF, type: COLLECTION_TYPE.SELF}
|
|
90
|
+
return setStartedOrCompletedStatus(contentId, collection, false, {isOffline: true, hierarchy})
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* @param contentId
|
|
95
|
+
* @param collection - Collection context; defaults to self
|
|
96
|
+
* @param hierarchy - Content hierarchy used to update parent progress offline
|
|
97
|
+
* @param options.skipPush - Skip queuing the reset for server sync (default false)
|
|
98
|
+
*/
|
|
99
|
+
export async function contentStatusResetOffline(contentId: number, collection: CollectionParameter = null, hierarchy: HierarchyParameter, {skipPush = false} = {}) {
|
|
100
|
+
collection = collection ?? {id: COLLECTION_ID_SELF, type: COLLECTION_TYPE.SELF}
|
|
101
|
+
return resetStatus(contentId, collection, {hierarchy, skipPush})
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
|