musora-content-services 2.104.9 → 2.106.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/CHANGELOG.md +20 -0
- package/package.json +1 -1
- package/src/contentTypeConfig.js +0 -8
- package/src/services/awards/award-callbacks.js +2 -0
- package/src/services/awards/award-query.js +8 -14
- package/src/services/awards/internal/award-definitions.js +5 -2
- package/src/services/awards/internal/award-manager.js +1 -1
- package/src/services/awards/internal/certificate-builder.js +1 -4
- package/src/services/awards/types.d.ts +1 -0
- package/src/services/awards/types.js +5 -2
- package/src/services/contentProgress.js +39 -31
- package/src/services/progress-row/method-card.js +1 -1
- package/src/services/sanity.js +23 -0
- package/src/services/sync/errors/index.ts +8 -0
- package/src/services/sync/errors/validators.ts +42 -0
- package/src/services/sync/fetch.ts +1 -1
- package/src/services/sync/models/ContentLike.ts +2 -1
- package/src/services/sync/models/ContentProgress.ts +31 -11
- package/src/services/sync/models/Practice.ts +32 -9
- package/src/services/sync/models/PracticeDayNote.ts +4 -2
- package/src/services/sync/models/UserAwardProgress.ts +17 -6
- package/src/services/sync/repositories/content-progress.ts +8 -15
- package/src/services/sync/repositories/user-award-progress.ts +4 -4
- package/src/services/sync/schema/index.ts +4 -4
- package/src/services/user/types.d.ts +0 -2
- package/src/services/user/types.js +0 -2
- package/src/services/userActivity.js +2 -3
- package/test/awards/award-completion-flow.test.js +2 -1
- package/.claude/settings.local.json +0 -9
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,26 @@
|
|
|
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.106.0](https://github.com/railroadmedia/musora-content-services/compare/v2.105.0...v2.106.0) (2025-12-18)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
### Features
|
|
9
|
+
|
|
10
|
+
* Add some validators prior to writing to watermelon ([#663](https://github.com/railroadmedia/musora-content-services/issues/663)) ([9eeedac](https://github.com/railroadmedia/musora-content-services/commit/9eeedac5a3cf8b4eedd091fcb8252ffd5bae6c85))
|
|
11
|
+
|
|
12
|
+
## [2.105.0](https://github.com/railroadmedia/musora-content-services/compare/v2.104.9...v2.105.0) (2025-12-17)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
### Features
|
|
16
|
+
|
|
17
|
+
* Remove xp and total_xp ([#656](https://github.com/railroadmedia/musora-content-services/issues/656)) ([e6238a6](https://github.com/railroadmedia/musora-content-services/commit/e6238a605e095dc81e152c25f0abf4885b2bb091))
|
|
18
|
+
* update type for method progress card ([#662](https://github.com/railroadmedia/musora-content-services/issues/662)) ([ad2d6df](https://github.com/railroadmedia/musora-content-services/commit/ad2d6df35b321afd7df1a302ee6bfbcbce1a31b7))
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
### Bug Fixes
|
|
22
|
+
|
|
23
|
+
* Minor progress fixes ([#654](https://github.com/railroadmedia/musora-content-services/issues/654)) ([202cceb](https://github.com/railroadmedia/musora-content-services/commit/202ccebc85d119ce9a63007e600a8fd92c3f94d7))
|
|
24
|
+
|
|
5
25
|
### [2.104.9](https://github.com/railroadmedia/musora-content-services/compare/v2.104.8...v2.104.9) (2025-12-17)
|
|
6
26
|
|
|
7
27
|
### [2.104.8](https://github.com/railroadmedia/musora-content-services/compare/v2.104.7...v2.104.8) (2025-12-16)
|
package/package.json
CHANGED
package/src/contentTypeConfig.js
CHANGED
|
@@ -425,8 +425,6 @@ export let contentTypeConfig = {
|
|
|
425
425
|
'"instructors": instructor[]->name',
|
|
426
426
|
`"description": ${descriptionField}`,
|
|
427
427
|
`"resource": ${resourcesField}`,
|
|
428
|
-
'xp',
|
|
429
|
-
'total_xp',
|
|
430
428
|
`"lessons": child[]->{
|
|
431
429
|
"id": railcontent_id,
|
|
432
430
|
title,
|
|
@@ -515,11 +513,9 @@ export let contentTypeConfig = {
|
|
|
515
513
|
pack: {
|
|
516
514
|
fields: [
|
|
517
515
|
'"lesson_count": coalesce(count(child[]->.child[]->), 0)',
|
|
518
|
-
'xp',
|
|
519
516
|
`"description": ${descriptionField}`,
|
|
520
517
|
'"instructors": instructor[]->{ "id": railcontent_id, name, "thumbnail_url": thumbnail_url.asset->url }',
|
|
521
518
|
'"logo_image_url": logo_image_url.asset->url',
|
|
522
|
-
'total_xp',
|
|
523
519
|
`"resources": ${resourcesField}`,
|
|
524
520
|
'"thumbnail": thumbnail.asset->url',
|
|
525
521
|
'"light_mode_logo": light_mode_logo_url.asset->url',
|
|
@@ -552,7 +548,6 @@ export let contentTypeConfig = {
|
|
|
552
548
|
'"light_mode_logo": light_mode_logo_url.asset->url',
|
|
553
549
|
'"dark_mode_logo": dark_mode_logo_url.asset->url',
|
|
554
550
|
`"description": ${descriptionField}`,
|
|
555
|
-
'total_xp',
|
|
556
551
|
],
|
|
557
552
|
childFields: [`"description": ${descriptionField}`],
|
|
558
553
|
},
|
|
@@ -574,7 +569,6 @@ export let contentTypeConfig = {
|
|
|
574
569
|
title,
|
|
575
570
|
"type": _type,
|
|
576
571
|
"description": ${descriptionField},
|
|
577
|
-
xp,
|
|
578
572
|
web_url_path,
|
|
579
573
|
"url": web_url_path,
|
|
580
574
|
}`,
|
|
@@ -586,8 +580,6 @@ export let contentTypeConfig = {
|
|
|
586
580
|
'"instructors": instructor[]->name',
|
|
587
581
|
`"description": ${descriptionField}`,
|
|
588
582
|
`"resource": ${resourcesField}`,
|
|
589
|
-
'xp',
|
|
590
|
-
'total_xp',
|
|
591
583
|
`"lessons": child[]->{
|
|
592
584
|
"id": railcontent_id,
|
|
593
585
|
title,
|
|
@@ -21,6 +21,7 @@ let progressUpdateCallback = null
|
|
|
21
21
|
* - `awardId` - Unique Sanity award ID
|
|
22
22
|
* - `name` - Display name of the award
|
|
23
23
|
* - `badge` - URL to badge image
|
|
24
|
+
* - `contentType` - Content type ('guided-course' or 'learning-path-v2')
|
|
24
25
|
* - `completed_at` - ISO timestamp
|
|
25
26
|
* - `isCompleted` - Boolean indicating the award is completed (always true for granted awards)
|
|
26
27
|
* - `completion_data.message` - Pre-generated congratulations message
|
|
@@ -72,6 +73,7 @@ export function registerAwardCallback(callback) {
|
|
|
72
73
|
awardId: awardId,
|
|
73
74
|
name: definition.name,
|
|
74
75
|
badge: definition.badge,
|
|
76
|
+
contentType: definition.content_type,
|
|
75
77
|
completed_at: completionData.completed_at,
|
|
76
78
|
isCompleted: true,
|
|
77
79
|
completionData: {
|
|
@@ -136,9 +136,7 @@ export async function getContentAwards(contentId) {
|
|
|
136
136
|
instructorName: def.instructor_name,
|
|
137
137
|
progressPercentage: userProgress?.progress_percentage ?? 0,
|
|
138
138
|
isCompleted: userProgress ? UserAwardProgressRepository.isCompleted(userProgress) : false,
|
|
139
|
-
completedAt: userProgress?.completed_at
|
|
140
|
-
? new Date(userProgress.completed_at).toISOString()
|
|
141
|
-
: null,
|
|
139
|
+
completedAt: userProgress?.completed_at,
|
|
142
140
|
completionData
|
|
143
141
|
}
|
|
144
142
|
})
|
|
@@ -168,8 +166,7 @@ export async function getContentAwards(contentId) {
|
|
|
168
166
|
* - Badge and award images for display
|
|
169
167
|
* - Completion date for "Earned on X" display
|
|
170
168
|
* - `completionData.message` - Pre-generated congratulations text
|
|
171
|
-
* - `completionData.
|
|
172
|
-
* - `completionData.days_user_practiced` - Days spent earning this award
|
|
169
|
+
* - `completionData.XXX` - other fields are award type dependant
|
|
173
170
|
*
|
|
174
171
|
* Returns empty array `[]` on error (never throws).
|
|
175
172
|
*
|
|
@@ -226,16 +223,14 @@ export async function getContentAwards(contentId) {
|
|
|
226
223
|
*/
|
|
227
224
|
export async function getCompletedAwards(brand = null, options = {}) {
|
|
228
225
|
try {
|
|
229
|
-
const allProgress = await db.userAwardProgress.
|
|
226
|
+
const allProgress = await db.userAwardProgress.getCompleted()
|
|
230
227
|
|
|
231
228
|
const completed = allProgress.data.filter(p =>
|
|
232
229
|
p.progress_percentage === 100 && p.completed_at !== null
|
|
233
230
|
)
|
|
234
|
-
|
|
235
231
|
let awards = await Promise.all(
|
|
236
232
|
completed.map(async (progress) => {
|
|
237
233
|
const definition = await awardDefinitions.getById(progress.award_id)
|
|
238
|
-
|
|
239
234
|
if (!definition) {
|
|
240
235
|
return null
|
|
241
236
|
}
|
|
@@ -243,24 +238,24 @@ export async function getCompletedAwards(brand = null, options = {}) {
|
|
|
243
238
|
if (brand && definition.brand !== brand) {
|
|
244
239
|
return null
|
|
245
240
|
}
|
|
246
|
-
|
|
247
|
-
const
|
|
248
|
-
|
|
241
|
+
const completionData = definition.type === awardDefinitions.CONTENT_AWARD ? enhanceCompletionData(progress.completion_data) : progress.completion_data;
|
|
242
|
+
const hasCertificate = definition.type === awardDefinitions.CONTENT_AWARD
|
|
249
243
|
return {
|
|
250
244
|
awardId: progress.award_id,
|
|
251
245
|
awardTitle: definition.name,
|
|
246
|
+
awardType: definition.type,
|
|
252
247
|
badge: definition.badge,
|
|
253
248
|
award: definition.award,
|
|
254
249
|
brand: definition.brand,
|
|
250
|
+
hasCertificate: hasCertificate,
|
|
255
251
|
instructorName: definition.instructor_name,
|
|
256
252
|
progressPercentage: progress.progress_percentage,
|
|
257
253
|
isCompleted: true,
|
|
258
|
-
completedAt:
|
|
254
|
+
completedAt: progress.completed_at,
|
|
259
255
|
completionData
|
|
260
256
|
}
|
|
261
257
|
})
|
|
262
258
|
)
|
|
263
|
-
|
|
264
259
|
awards = awards.filter(award => award !== null)
|
|
265
260
|
|
|
266
261
|
awards.sort((a, b) => new Date(b.completedAt).getTime() - new Date(a.completedAt).getTime())
|
|
@@ -269,7 +264,6 @@ export async function getCompletedAwards(brand = null, options = {}) {
|
|
|
269
264
|
const offset = options.offset || 0
|
|
270
265
|
awards = awards.slice(offset, offset + options.limit)
|
|
271
266
|
}
|
|
272
|
-
|
|
273
267
|
return awards
|
|
274
268
|
} catch (error) {
|
|
275
269
|
console.error('Failed to get completed awards:', error)
|
|
@@ -9,6 +9,9 @@
|
|
|
9
9
|
const STORAGE_KEY = 'musora_award_definitions_last_fetch'
|
|
10
10
|
|
|
11
11
|
class AwardDefinitionsService {
|
|
12
|
+
|
|
13
|
+
CONTENT_AWARD = 'content-award'
|
|
14
|
+
EXP_AWARD = 'exp-award'
|
|
12
15
|
constructor() {
|
|
13
16
|
/** @type {AwardDefinitionsMap} */
|
|
14
17
|
this.definitions = new Map()
|
|
@@ -90,7 +93,7 @@ class AwardDefinitionsService {
|
|
|
90
93
|
bypassPermissions: true,
|
|
91
94
|
}).buildFilter()
|
|
92
95
|
|
|
93
|
-
const query = `*[_type
|
|
96
|
+
const query = `*[_type in ['content-award', 'exp-award']] {
|
|
94
97
|
_id,
|
|
95
98
|
is_active,
|
|
96
99
|
name,
|
|
@@ -107,7 +110,7 @@ class AwardDefinitionsService {
|
|
|
107
110
|
'child_ids': content->child[${childFilter}]->railcontent_id,
|
|
108
111
|
}`
|
|
109
112
|
|
|
110
|
-
const awards = await fetchSanity(query, true, { processNeedAccess: false })
|
|
113
|
+
const awards = await fetchSanity(query, true, { processNeedAccess: false, processPageType: false })
|
|
111
114
|
|
|
112
115
|
this.definitions.clear()
|
|
113
116
|
this.contentIndex.clear()
|
|
@@ -99,7 +99,7 @@ export class AwardManager {
|
|
|
99
99
|
const popupMessage = AwardMessageGenerator.generatePopupMessage(completionData)
|
|
100
100
|
|
|
101
101
|
await db.userAwardProgress.recordAwardProgress(award._id, 100, {
|
|
102
|
-
completedAt: Date.
|
|
102
|
+
completedAt: new Date().toISOString(),
|
|
103
103
|
completionData,
|
|
104
104
|
progressData,
|
|
105
105
|
immediate: true
|
|
@@ -39,10 +39,7 @@ export async function buildCertificateData(awardId) {
|
|
|
39
39
|
return {
|
|
40
40
|
userId: globalConfig.sessionConfig.userId,
|
|
41
41
|
userName: userData?.display_name || userData?.name || 'User',
|
|
42
|
-
completedAt: userProgress.data.
|
|
43
|
-
? new Date(userProgress.data.completed_at * 1000).toISOString()
|
|
44
|
-
: new Date().toISOString(),
|
|
45
|
-
|
|
42
|
+
completedAt: userProgress.data?.completed_at ?? new Date().toISOString(),
|
|
46
43
|
awardId: awardDef._id,
|
|
47
44
|
awardType: awardDef.type || 'content-award',
|
|
48
45
|
awardTitle: awardDef.name,
|
|
@@ -37,10 +37,12 @@
|
|
|
37
37
|
* @typedef {Object} AwardInfo
|
|
38
38
|
* @property {string} awardId - Unique Sanity award ID
|
|
39
39
|
* @property {string} awardTitle - Display name of the award
|
|
40
|
+
* @property {string} awardType - Type of the award
|
|
41
|
+
* @property {boolean} hasCertificate - flag to indicate if the award includes a downloadable certificate
|
|
40
42
|
* @property {string} badge - URL to badge image
|
|
41
43
|
* @property {string} award - URL to award image
|
|
42
|
-
* @property {string} brand - Brand (drumeo, pianote, guitareo, singeo)
|
|
43
|
-
* @property {string} instructorName - Name of the instructor
|
|
44
|
+
* @property {string} brand - Brand (drumeo, pianote, guitareo, singeo, playbass)
|
|
45
|
+
* @property {string|null} instructorName - Name of the instructor
|
|
44
46
|
* @property {number} progressPercentage - Completion percentage (0-100). Progress is tracked per collection context for learning paths.
|
|
45
47
|
* @property {boolean} isCompleted - Whether the award is fully completed
|
|
46
48
|
* @property {string|null} completedAt - ISO timestamp of completion, or null if not completed
|
|
@@ -73,6 +75,7 @@
|
|
|
73
75
|
* @property {string} awardId - Unique Sanity award ID
|
|
74
76
|
* @property {string} name - Display name of the award
|
|
75
77
|
* @property {string} badge - URL to badge image
|
|
78
|
+
* @property {string} contentType - Content type ('guided-course' or 'learning-path-v2')
|
|
76
79
|
* @property {string} completed_at - ISO timestamp of completion
|
|
77
80
|
* @property {AwardCompletionData} completion_data - Practice statistics
|
|
78
81
|
*/
|
|
@@ -6,6 +6,7 @@ import { getNextLessonLessonParentTypes } from '../contentTypeConfig.js'
|
|
|
6
6
|
import { emitContentCompleted } from './progress-events'
|
|
7
7
|
import {getDailySession} from "./content-org/learning-paths.ts";
|
|
8
8
|
import {getToday} from "./dateUtils.js";
|
|
9
|
+
import { fetchBrandsByContentIds } from './sanity.js'
|
|
9
10
|
|
|
10
11
|
const STATE_STARTED = STATE.STARTED
|
|
11
12
|
const STATE_COMPLETED = STATE.COMPLETED
|
|
@@ -316,48 +317,25 @@ async function getByIdsAndCollections(tuples, dataKey, defaultValue) {
|
|
|
316
317
|
}
|
|
317
318
|
|
|
318
319
|
export async function getAllStarted(limit = null) {
|
|
319
|
-
return db.contentProgress.startedIds(limit)
|
|
320
|
+
return db.contentProgress.startedIds(limit)
|
|
320
321
|
}
|
|
321
322
|
|
|
322
323
|
export async function getAllCompleted(limit = null) {
|
|
323
|
-
return db.contentProgress.completedIds(limit)
|
|
324
|
+
return db.contentProgress.completedIds(limit)
|
|
324
325
|
}
|
|
325
326
|
|
|
326
327
|
export async function getAllCompletedByIds(contentIds) {
|
|
327
328
|
return db.contentProgress.completedByContentIds(normalizeContentIds(contentIds))
|
|
328
329
|
}
|
|
329
330
|
|
|
331
|
+
/**
|
|
332
|
+
* Fetches content **IDs** for items that were started or completed.
|
|
333
|
+
*/
|
|
330
334
|
export async function getAllStartedOrCompleted({
|
|
331
|
-
onlyIds = true,
|
|
332
335
|
brand = null,
|
|
333
336
|
limit = null,
|
|
334
337
|
} = {}) {
|
|
335
|
-
|
|
336
|
-
const filters = {
|
|
337
|
-
brand: brand ?? undefined,
|
|
338
|
-
updatedAfter: agoInSeconds,
|
|
339
|
-
limit: limit ?? undefined,
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
if (onlyIds) {
|
|
343
|
-
return db.contentProgress
|
|
344
|
-
.startedOrCompletedIds(filters)
|
|
345
|
-
.then((r) => r.data.map((id) => parseInt(id)))
|
|
346
|
-
} else {
|
|
347
|
-
return db.contentProgress.startedOrCompleted(filters).then((r) => {
|
|
348
|
-
return Object.fromEntries(
|
|
349
|
-
r.data.map((p) => [
|
|
350
|
-
p.content_id,
|
|
351
|
-
{
|
|
352
|
-
last_update: p.updated_at,
|
|
353
|
-
progress: p.progress_percent,
|
|
354
|
-
status: p.state,
|
|
355
|
-
brand: p.content_brand,
|
|
356
|
-
},
|
|
357
|
-
])
|
|
358
|
-
)
|
|
359
|
-
})
|
|
360
|
-
}
|
|
338
|
+
return await _getAllStartedOrCompleted({ brand, limit }).then(recs => recs.map(rec => rec.content_id))
|
|
361
339
|
}
|
|
362
340
|
|
|
363
341
|
/**
|
|
@@ -378,11 +356,41 @@ export async function getAllStartedOrCompleted({
|
|
|
378
356
|
* console.log(progressMap[123]); // => 52
|
|
379
357
|
*/
|
|
380
358
|
export async function getStartedOrCompletedProgressOnly({ brand = undefined } = {}) {
|
|
381
|
-
return
|
|
382
|
-
return Object.fromEntries(r.
|
|
359
|
+
return _getAllStartedOrCompleted({ brand }).then((r) => {
|
|
360
|
+
return Object.fromEntries(r.map((p) => [p.content_id, p.progress_percent]))
|
|
383
361
|
})
|
|
384
362
|
}
|
|
385
363
|
|
|
364
|
+
async function _getAllStartedOrCompleted({
|
|
365
|
+
brand = null,
|
|
366
|
+
limit = null,
|
|
367
|
+
} = {}) {
|
|
368
|
+
const agoInSeconds = Math.floor(Date.now() / 1000) - 60 * 24 * 60 * 60 // 60 days in seconds
|
|
369
|
+
const baseFilters = {
|
|
370
|
+
updatedAfter: agoInSeconds,
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
if (!brand) {
|
|
374
|
+
return await db.contentProgress.startedOrCompleted({ ...baseFilters, limit }).then(r => r.data)
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// content_brand can be null (i.e., when progress records created locally)
|
|
378
|
+
// TODO: eventually put content metadata into watermelon so we can
|
|
379
|
+
// always have brand info in progress records and avoid all this
|
|
380
|
+
|
|
381
|
+
// for now though, null-ish brands shouldn't be too numerous, so safe to have undefined limit
|
|
382
|
+
const [strictRecs, looseRecs] = await Promise.all([
|
|
383
|
+
db.contentProgress.startedOrCompleted({ ...baseFilters, brand, limit }),
|
|
384
|
+
db.contentProgress.startedOrCompleted({ ...baseFilters, brand: null, limit: undefined })
|
|
385
|
+
]);
|
|
386
|
+
|
|
387
|
+
const map = await fetchBrandsByContentIds(looseRecs.data.map(r => r.content_id));
|
|
388
|
+
const filteredLooseRecs = looseRecs.data.filter(r => map[r.content_id] === brand).map(r => ({ ...r, content_brand: brand }));
|
|
389
|
+
|
|
390
|
+
const records = [...strictRecs.data, ...filteredLooseRecs].sort((a, b) => b.updated_at - a.updated_at).slice(0, limit || undefined);
|
|
391
|
+
return records;
|
|
392
|
+
}
|
|
393
|
+
|
|
386
394
|
/**
|
|
387
395
|
* Record watch session
|
|
388
396
|
* @return {string} sessionId - provide in future calls to update progress
|
package/src/services/sanity.js
CHANGED
|
@@ -2083,3 +2083,26 @@ export async function fetchOwnedContent(
|
|
|
2083
2083
|
|
|
2084
2084
|
return fetchSanity(query, true)
|
|
2085
2085
|
}
|
|
2086
|
+
|
|
2087
|
+
/**
|
|
2088
|
+
* Fetch brands for given content IDs.
|
|
2089
|
+
*
|
|
2090
|
+
* @param {Array<number>} contentIds - Array of railcontent IDs
|
|
2091
|
+
* @returns {Promise<Object>} - A promise that resolves to an object mapping content IDs to brands
|
|
2092
|
+
*/
|
|
2093
|
+
export async function fetchBrandsByContentIds(contentIds) {
|
|
2094
|
+
if (!contentIds || contentIds.length === 0) {
|
|
2095
|
+
return {}
|
|
2096
|
+
}
|
|
2097
|
+
const idsString = contentIds.join(',')
|
|
2098
|
+
const query = `*[railcontent_id in [${idsString}]]{
|
|
2099
|
+
railcontent_id,
|
|
2100
|
+
brand
|
|
2101
|
+
}`
|
|
2102
|
+
const results = await fetchSanity(query, true)
|
|
2103
|
+
const brandMap = {}
|
|
2104
|
+
results.forEach((item) => {
|
|
2105
|
+
brandMap[item.railcontent_id] = item.brand
|
|
2106
|
+
})
|
|
2107
|
+
return brandMap
|
|
2108
|
+
}
|
|
@@ -47,3 +47,11 @@ export class SyncUnexpectedError extends SyncError {
|
|
|
47
47
|
Object.setPrototypeOf(this, new.target.prototype)
|
|
48
48
|
}
|
|
49
49
|
}
|
|
50
|
+
|
|
51
|
+
export class SyncValidationError extends SyncError {
|
|
52
|
+
constructor(message: string, value: any, expected: any) {
|
|
53
|
+
super('validationError', { message: message, value: value, expected: expected })
|
|
54
|
+
this.name = 'SyncValidationError'
|
|
55
|
+
Object.setPrototypeOf(this, new.target.prototype)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { SyncValidationError } from './index'
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
export function throwIfNotNumber(val: any) {
|
|
5
|
+
// note: this will accept decimal values
|
|
6
|
+
if (typeof val !== 'number') throw new SyncValidationError('Sync value is not a number: ' + val, typeof val, 'number');
|
|
7
|
+
return val
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function throwIfNotString(val: any) {
|
|
11
|
+
if (typeof val !== 'string') throw new SyncValidationError('Sync value is not a string: ' + val, typeof val, 'string');
|
|
12
|
+
return val
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function throwIfNotBoolean(val: any) {
|
|
16
|
+
if (typeof val !== 'boolean') throw new SyncValidationError('Sync value is not a boolean: ' + val, typeof val, 'boolean');
|
|
17
|
+
return val
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function throwIfNotNullableNumber(val: any) {
|
|
21
|
+
return val === null ? val : throwIfNotNumber(val)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function throwIfNotNullableString(val: any) {
|
|
25
|
+
return val === null ? val : throwIfNotString(val)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function throwIfOutsideRange(val: number, minimum?: number, maximum?: number) {
|
|
29
|
+
if (minimum !== undefined && val < minimum) throw new SyncValidationError('Sync value is less than minimum value ' + minimum + ': ' + val, val, null);
|
|
30
|
+
if (maximum !== undefined && val > maximum) throw new SyncValidationError('Sync value is greater than maximum value ' + maximum + ': ' + val, val, null);
|
|
31
|
+
return val
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function throwIfMaxLengthExceeded(val: string, maximum: number) {
|
|
35
|
+
if (val.length > maximum) throw new SyncValidationError('Sync value exceeds the maximum length ' + maximum + ': ' + val, val, null);
|
|
36
|
+
return val
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function throwIfInvalidEnumValue(val: string, enumClass: any) {
|
|
40
|
+
if (!Object.values(enumClass).includes(val)) throw new SyncValidationError('Sync value is invalid enum value: ' + val, val, enumClass);
|
|
41
|
+
return val
|
|
42
|
+
}
|
|
@@ -34,7 +34,7 @@ type SyncPushFetchFailureResponse = SyncResponseBase & {
|
|
|
34
34
|
failureType: 'fetch'
|
|
35
35
|
isRetryable: boolean
|
|
36
36
|
}
|
|
37
|
-
type SyncPushFailureResponse = SyncResponseBase & {
|
|
37
|
+
export type SyncPushFailureResponse = SyncResponseBase & {
|
|
38
38
|
ok: false,
|
|
39
39
|
failureType: 'error'
|
|
40
40
|
originalError: Error
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { SYNC_TABLES } from '../schema'
|
|
2
2
|
import BaseModel from './Base'
|
|
3
|
+
import { throwIfNotNumber } from '../errors/validators'
|
|
3
4
|
|
|
4
5
|
export default class ContentLike extends BaseModel<{
|
|
5
6
|
content_id: number
|
|
@@ -11,6 +12,6 @@ export default class ContentLike extends BaseModel<{
|
|
|
11
12
|
}
|
|
12
13
|
|
|
13
14
|
set content_id(value: number) {
|
|
14
|
-
this._setRaw('content_id', value)
|
|
15
|
+
this._setRaw('content_id', throwIfNotNumber(value))
|
|
15
16
|
}
|
|
16
17
|
}
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import BaseModel from './Base'
|
|
2
2
|
import { SYNC_TABLES } from '../schema'
|
|
3
|
+
import {
|
|
4
|
+
throwIfInvalidEnumValue,
|
|
5
|
+
throwIfNotNullableNumber,
|
|
6
|
+
throwIfNotNullableString,
|
|
7
|
+
throwIfNotNumber,
|
|
8
|
+
throwIfOutsideRange,
|
|
9
|
+
} from '../errors/validators'
|
|
3
10
|
|
|
4
11
|
export enum COLLECTION_TYPE {
|
|
5
12
|
SELF = 'self',
|
|
@@ -16,6 +23,7 @@ export enum STATE {
|
|
|
16
23
|
|
|
17
24
|
export default class ContentProgress extends BaseModel<{
|
|
18
25
|
content_id: number
|
|
26
|
+
content_brand: string | null
|
|
19
27
|
collection_type: COLLECTION_TYPE
|
|
20
28
|
collection_id: number
|
|
21
29
|
state: STATE
|
|
@@ -28,7 +36,7 @@ export default class ContentProgress extends BaseModel<{
|
|
|
28
36
|
return this._getRaw('content_id') as number
|
|
29
37
|
}
|
|
30
38
|
get content_brand() {
|
|
31
|
-
return this._getRaw('content_brand') as string
|
|
39
|
+
return this._getRaw('content_brand') as string | null
|
|
32
40
|
}
|
|
33
41
|
get state() {
|
|
34
42
|
return this._getRaw('state') as STATE
|
|
@@ -47,25 +55,37 @@ export default class ContentProgress extends BaseModel<{
|
|
|
47
55
|
}
|
|
48
56
|
|
|
49
57
|
set content_id(value: number) {
|
|
50
|
-
|
|
58
|
+
// unsigned int
|
|
59
|
+
throwIfNotNumber(value)
|
|
60
|
+
this._setRaw('content_id', throwIfOutsideRange(value, 0))
|
|
51
61
|
}
|
|
52
|
-
set content_brand(value: string) {
|
|
53
|
-
this._setRaw('content_brand', value)
|
|
54
|
-
}
|
|
55
|
-
set state(value: STATE) {
|
|
56
|
-
this._setRaw('state', value)
|
|
62
|
+
set content_brand(value: string | null) {
|
|
63
|
+
this._setRaw('content_brand', throwIfNotNullableString(value))
|
|
57
64
|
}
|
|
65
|
+
// IMPORTANT: progress percent only moves forward and is clamped between 0 and 100
|
|
66
|
+
// also has implications for last-write-wins sync strategy
|
|
58
67
|
set progress_percent(value: number) {
|
|
59
|
-
|
|
68
|
+
// tinyint unsigned
|
|
69
|
+
throwIfNotNumber(value)
|
|
70
|
+
throwIfOutsideRange(value, 0, 100)
|
|
71
|
+
const percent = value === 0 ? 0 : Math.max(value, this.progress_percent)
|
|
72
|
+
|
|
73
|
+
this._setRaw('progress_percent', percent)
|
|
74
|
+
this._setRaw('state', percent === 100 ? STATE.COMPLETED : STATE.STARTED)
|
|
60
75
|
}
|
|
61
76
|
set collection_type(value: COLLECTION_TYPE) {
|
|
62
|
-
|
|
77
|
+
// enum collection_type
|
|
78
|
+
this._setRaw('collection_type', throwIfInvalidEnumValue(value, COLLECTION_TYPE))
|
|
63
79
|
}
|
|
64
80
|
set collection_id(value: number) {
|
|
65
|
-
|
|
81
|
+
// unsigned mediumint 16777215
|
|
82
|
+
throwIfNotNumber(value)
|
|
83
|
+
this._setRaw('collection_id', throwIfOutsideRange(value, 0, 16777215))
|
|
66
84
|
}
|
|
67
85
|
set resume_time_seconds(value: number | null) {
|
|
68
|
-
|
|
86
|
+
// smallint unsigned
|
|
87
|
+
throwIfNotNullableNumber(value)
|
|
88
|
+
this._setRaw('resume_time_seconds', value !== null ? throwIfOutsideRange(value, 0, 65535) : value)
|
|
69
89
|
}
|
|
70
90
|
|
|
71
91
|
}
|
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
import { SYNC_TABLES } from '../schema'
|
|
2
2
|
import BaseModel from './Base'
|
|
3
|
+
import {
|
|
4
|
+
throwIfMaxLengthExceeded,
|
|
5
|
+
throwIfNotBoolean,
|
|
6
|
+
throwIfNotNullableNumber,
|
|
7
|
+
throwIfNotNullableString,
|
|
8
|
+
throwIfNotNumber,
|
|
9
|
+
throwIfNotString,
|
|
10
|
+
throwIfOutsideRange,
|
|
11
|
+
} from '../errors/validators'
|
|
3
12
|
|
|
4
13
|
export default class Practice extends BaseModel<{
|
|
5
14
|
manual_id: string | null
|
|
@@ -43,30 +52,44 @@ export default class Practice extends BaseModel<{
|
|
|
43
52
|
}
|
|
44
53
|
|
|
45
54
|
set manual_id(value: string | null) {
|
|
46
|
-
|
|
55
|
+
// char(26)
|
|
56
|
+
throwIfNotNullableString(value)
|
|
57
|
+
this._setRaw('manual_id', value !== null ? throwIfMaxLengthExceeded(value, 26) : value)
|
|
47
58
|
}
|
|
48
59
|
set content_id(value: number | null) {
|
|
49
|
-
|
|
60
|
+
// int unsigned
|
|
61
|
+
throwIfNotNullableNumber(value)
|
|
62
|
+
this._setRaw('content_id', value !== null ? throwIfOutsideRange(value, 0) : value)
|
|
50
63
|
}
|
|
51
64
|
set date(value: string) {
|
|
52
|
-
this._setRaw('date', value)
|
|
65
|
+
this._setRaw('date', throwIfNotString(value))
|
|
53
66
|
}
|
|
54
67
|
set auto(value: boolean) {
|
|
55
|
-
|
|
68
|
+
// tinyint(1)
|
|
69
|
+
this._setRaw('auto', throwIfNotBoolean(value))
|
|
56
70
|
}
|
|
57
71
|
set duration_seconds(value: number) {
|
|
58
|
-
|
|
72
|
+
throwIfNotNumber(value)
|
|
73
|
+
this._setRaw('duration_seconds', throwIfOutsideRange(value, 0, 59999))
|
|
59
74
|
}
|
|
60
75
|
set title(value: string | null) {
|
|
61
|
-
|
|
76
|
+
// varchar(64)
|
|
77
|
+
throwIfNotNullableString(value)
|
|
78
|
+
this._setRaw('title', value !== null ? throwIfMaxLengthExceeded(value, 64) : value)
|
|
62
79
|
}
|
|
63
80
|
set thumbnail_url(value: string | null) {
|
|
64
|
-
|
|
81
|
+
// varchar(255)
|
|
82
|
+
throwIfNotNullableString(value)
|
|
83
|
+
this._setRaw('thumbnail_url', value !== null ? throwIfMaxLengthExceeded(value, 255) : value)
|
|
65
84
|
}
|
|
66
85
|
set category_id(value: number | null) {
|
|
67
|
-
|
|
86
|
+
// tinyint unsigned
|
|
87
|
+
throwIfNotNullableNumber(value)
|
|
88
|
+
this._setRaw('category_id', value !== null ? throwIfOutsideRange(value, 0, 255) : value)
|
|
68
89
|
}
|
|
69
90
|
set instrument_id(value: number | null) {
|
|
70
|
-
|
|
91
|
+
// tinyint unsigned
|
|
92
|
+
throwIfNotNullableNumber(value)
|
|
93
|
+
this._setRaw('instrument_id', value !== null ? throwIfOutsideRange(value, 0, 255) : value)
|
|
71
94
|
}
|
|
72
95
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { SYNC_TABLES } from '../schema'
|
|
2
2
|
import BaseModel from './Base'
|
|
3
|
+
import { throwIfMaxLengthExceeded, throwIfNotString } from '../errors/validators'
|
|
3
4
|
|
|
4
5
|
export default class PracticeDayNote extends BaseModel<{
|
|
5
6
|
date: string
|
|
@@ -15,9 +16,10 @@ export default class PracticeDayNote extends BaseModel<{
|
|
|
15
16
|
}
|
|
16
17
|
|
|
17
18
|
set date(value: string) {
|
|
18
|
-
this._setRaw('date', value)
|
|
19
|
+
this._setRaw('date', throwIfNotString(value))
|
|
19
20
|
}
|
|
20
21
|
set notes(value: string) {
|
|
21
|
-
|
|
22
|
+
throwIfNotString(value)
|
|
23
|
+
this._setRaw('notes', throwIfMaxLengthExceeded(value, 3000))
|
|
22
24
|
}
|
|
23
25
|
}
|
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
import BaseModel from './Base'
|
|
2
2
|
import { SYNC_TABLES } from '../schema'
|
|
3
3
|
import type { CompletionData } from '../../awards/types'
|
|
4
|
+
import {
|
|
5
|
+
throwIfMaxLengthExceeded,
|
|
6
|
+
throwIfNotNullableString,
|
|
7
|
+
throwIfNotNumber,
|
|
8
|
+
throwIfNotString,
|
|
9
|
+
throwIfOutsideRange,
|
|
10
|
+
} from '../errors/validators'
|
|
4
11
|
|
|
5
12
|
export default class UserAwardProgress extends BaseModel<{
|
|
6
13
|
award_id: string
|
|
7
14
|
progress_percentage: number
|
|
8
|
-
completed_at:
|
|
15
|
+
completed_at: string | null
|
|
9
16
|
progress_data: string | null
|
|
10
17
|
completion_data: string | null
|
|
11
18
|
}> {
|
|
@@ -20,7 +27,7 @@ export default class UserAwardProgress extends BaseModel<{
|
|
|
20
27
|
}
|
|
21
28
|
|
|
22
29
|
get completed_at() {
|
|
23
|
-
return this._getRaw('completed_at') as
|
|
30
|
+
return this._getRaw('completed_at') as string | null
|
|
24
31
|
}
|
|
25
32
|
|
|
26
33
|
get progress_data() {
|
|
@@ -34,15 +41,19 @@ export default class UserAwardProgress extends BaseModel<{
|
|
|
34
41
|
}
|
|
35
42
|
|
|
36
43
|
set award_id(value: string) {
|
|
37
|
-
|
|
44
|
+
// varchar(255)
|
|
45
|
+
throwIfNotString(value)
|
|
46
|
+
this._setRaw('award_id', throwIfMaxLengthExceeded(value, 255))
|
|
38
47
|
}
|
|
39
48
|
|
|
40
49
|
set progress_percentage(value: number) {
|
|
41
|
-
|
|
50
|
+
// int
|
|
51
|
+
throwIfNotNumber(value)
|
|
52
|
+
this._setRaw('progress_percentage', throwIfOutsideRange(value, 0, 100))
|
|
42
53
|
}
|
|
43
54
|
|
|
44
|
-
set completed_at(value:
|
|
45
|
-
this._setRaw('completed_at', value)
|
|
55
|
+
set completed_at(value: string | null) {
|
|
56
|
+
this._setRaw('completed_at', throwIfNotNullableString(value))
|
|
46
57
|
}
|
|
47
58
|
|
|
48
59
|
set progress_data(value: any) {
|
|
@@ -13,7 +13,7 @@ export interface CollectionParameter {
|
|
|
13
13
|
export default class ProgressRepository extends SyncRepository<ContentProgress> {
|
|
14
14
|
// null collection only
|
|
15
15
|
async startedIds(limit?: number) {
|
|
16
|
-
return this.
|
|
16
|
+
return this.queryAll(...[
|
|
17
17
|
Q.where('collection_type', COLLECTION_TYPE.SELF),
|
|
18
18
|
Q.where('collection_id', COLLECTION_ID_SELF),
|
|
19
19
|
|
|
@@ -21,12 +21,12 @@ export default class ProgressRepository extends SyncRepository<ContentProgress>
|
|
|
21
21
|
Q.sortBy('updated_at', 'desc'),
|
|
22
22
|
|
|
23
23
|
...(limit ? [Q.take(limit)] : []),
|
|
24
|
-
])
|
|
24
|
+
]).then((r) => r.data.map((r) => r.content_id))
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
// null collection only
|
|
28
28
|
async completedIds(limit?: number) {
|
|
29
|
-
return this.
|
|
29
|
+
return this.queryAll(...[
|
|
30
30
|
Q.where('collection_type', COLLECTION_TYPE.SELF),
|
|
31
31
|
Q.where('collection_id', COLLECTION_ID_SELF),
|
|
32
32
|
|
|
@@ -34,7 +34,7 @@ export default class ProgressRepository extends SyncRepository<ContentProgress>
|
|
|
34
34
|
Q.sortBy('updated_at', 'desc'),
|
|
35
35
|
|
|
36
36
|
...(limit ? [Q.take(limit)] : []),
|
|
37
|
-
])
|
|
37
|
+
]).then((r) => r.data.map((r) => r.content_id))
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
//this _specifically_ needs to get content_ids from ALL collection_types (including null)
|
|
@@ -50,17 +50,12 @@ export default class ProgressRepository extends SyncRepository<ContentProgress>
|
|
|
50
50
|
return this.queryAll(...this.startedOrCompletedClauses(opts))
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
// null collection only
|
|
54
|
-
async startedOrCompletedIds(opts: Parameters<typeof this.startedOrCompletedClauses>[0] = {}) {
|
|
55
|
-
return this.queryAllIds(...this.startedOrCompletedClauses(opts))
|
|
56
|
-
}
|
|
57
|
-
|
|
58
53
|
// null collection only
|
|
59
54
|
private startedOrCompletedClauses(
|
|
60
55
|
opts: {
|
|
61
|
-
brand?: string
|
|
62
|
-
updatedAfter?: number
|
|
63
|
-
limit?: number
|
|
56
|
+
brand?: string | null
|
|
57
|
+
updatedAfter?: number,
|
|
58
|
+
limit?: number,
|
|
64
59
|
} = {}
|
|
65
60
|
) {
|
|
66
61
|
const clauses: Q.Clause[] = [
|
|
@@ -75,7 +70,7 @@ export default class ProgressRepository extends SyncRepository<ContentProgress>
|
|
|
75
70
|
clauses.push(Q.where('updated_at', Q.gte(opts.updatedAfter)))
|
|
76
71
|
}
|
|
77
72
|
|
|
78
|
-
if (opts.brand) {
|
|
73
|
+
if (typeof opts.brand != 'undefined') {
|
|
79
74
|
clauses.push(Q.where('content_brand', opts.brand))
|
|
80
75
|
}
|
|
81
76
|
|
|
@@ -146,7 +141,6 @@ export default class ProgressRepository extends SyncRepository<ContentProgress>
|
|
|
146
141
|
r.collection_type = collection?.type ?? COLLECTION_TYPE.SELF
|
|
147
142
|
r.collection_id = collection?.id ?? COLLECTION_ID_SELF
|
|
148
143
|
|
|
149
|
-
r.state = progressPct === 100 ? STATE.COMPLETED : STATE.STARTED
|
|
150
144
|
r.progress_percent = progressPct
|
|
151
145
|
|
|
152
146
|
if (typeof resumeTime != 'undefined') {
|
|
@@ -193,7 +187,6 @@ export default class ProgressRepository extends SyncRepository<ContentProgress>
|
|
|
193
187
|
r.collection_type = collection?.type ?? COLLECTION_TYPE.SELF
|
|
194
188
|
r.collection_id = collection?.id ?? COLLECTION_ID_SELF
|
|
195
189
|
|
|
196
|
-
r.state = progressPct === 100 ? STATE.COMPLETED : STATE.STARTED
|
|
197
190
|
r.progress_percent = progressPct
|
|
198
191
|
},
|
|
199
192
|
])
|
|
@@ -5,7 +5,7 @@ import type { AwardDefinition, CompletionData } from '../../awards/types'
|
|
|
5
5
|
import type { ModelSerialized } from '../serializers'
|
|
6
6
|
|
|
7
7
|
type AwardProgressData = {
|
|
8
|
-
completed_at:
|
|
8
|
+
completed_at: string | null
|
|
9
9
|
progress_percentage: number
|
|
10
10
|
}
|
|
11
11
|
|
|
@@ -18,7 +18,7 @@ export default class UserAwardProgressRepository extends SyncRepository<UserAwar
|
|
|
18
18
|
return progress.progress_percentage >= 0 && !UserAwardProgressRepository.isCompleted(progress)
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
static completedAtDate(progress: { completed_at:
|
|
21
|
+
static completedAtDate(progress: { completed_at: string | null }): Date | null {
|
|
22
22
|
return progress.completed_at ? new Date(progress.completed_at) : null
|
|
23
23
|
}
|
|
24
24
|
|
|
@@ -73,7 +73,7 @@ export default class UserAwardProgressRepository extends SyncRepository<UserAwar
|
|
|
73
73
|
awardId: string,
|
|
74
74
|
progressPercentage: number,
|
|
75
75
|
options?: {
|
|
76
|
-
completedAt?:
|
|
76
|
+
completedAt?: string | null
|
|
77
77
|
progressData?: any
|
|
78
78
|
completionData?: CompletionData | null
|
|
79
79
|
immediate?: boolean
|
|
@@ -104,7 +104,7 @@ export default class UserAwardProgressRepository extends SyncRepository<UserAwar
|
|
|
104
104
|
completionData: CompletionData
|
|
105
105
|
) {
|
|
106
106
|
return this.recordAwardProgress(awardId, 100, {
|
|
107
|
-
completedAt: Date.
|
|
107
|
+
completedAt: new Date().toISOString(),
|
|
108
108
|
completionData,
|
|
109
109
|
immediate: true
|
|
110
110
|
})
|
|
@@ -20,9 +20,9 @@ const contentProgressTable = tableSchema({
|
|
|
20
20
|
name: SYNC_TABLES.CONTENT_PROGRESS,
|
|
21
21
|
columns: [
|
|
22
22
|
{ name: 'content_id', type: 'number', isIndexed: true },
|
|
23
|
-
{ name: 'content_brand', type: 'string', isIndexed: true },
|
|
24
|
-
{ name: 'collection_type', type: 'string',
|
|
25
|
-
{ name: 'collection_id', type: 'number',
|
|
23
|
+
{ name: 'content_brand', type: 'string', isOptional: true, isIndexed: true },
|
|
24
|
+
{ name: 'collection_type', type: 'string', isIndexed: true },
|
|
25
|
+
{ name: 'collection_id', type: 'number', isIndexed: true },
|
|
26
26
|
{ name: 'state', type: 'string', isIndexed: true },
|
|
27
27
|
{ name: 'progress_percent', type: 'number' },
|
|
28
28
|
{ name: 'resume_time_seconds', type: 'number', isOptional: true },
|
|
@@ -61,7 +61,7 @@ const userAwardProgressTable = tableSchema({
|
|
|
61
61
|
columns: [
|
|
62
62
|
{ name: 'award_id', type: 'string', isIndexed: true },
|
|
63
63
|
{ name: 'progress_percentage', type: 'number' },
|
|
64
|
-
{ name: 'completed_at', type: '
|
|
64
|
+
{ name: 'completed_at', type: 'string', isOptional: true, isIndexed: true },
|
|
65
65
|
{ name: 'progress_data', type: 'string', isOptional: true },
|
|
66
66
|
{ name: 'completion_data', type: 'string', isOptional: true },
|
|
67
67
|
{ name: 'created_at', type: 'number' },
|
|
@@ -42,9 +42,7 @@ export interface User {
|
|
|
42
42
|
revenuecat_origin_app_user_id: string | null
|
|
43
43
|
is_drumeo_lifetime_member: number
|
|
44
44
|
access_level: string
|
|
45
|
-
total_xp: number
|
|
46
45
|
brand_method_levels: BrandMethodLevels
|
|
47
|
-
brand_total_xp: BrandTotalXp
|
|
48
46
|
brand_minutes_practiced: BrandTimePracticed
|
|
49
47
|
brand_seconds_practiced: BrandTimePracticed
|
|
50
48
|
guitar_playing_since_year: number | null
|
|
@@ -46,9 +46,7 @@
|
|
|
46
46
|
* @property {string|null} revenuecat_origin_app_user_id
|
|
47
47
|
* @property {number} is_drumeo_lifetime_member
|
|
48
48
|
* @property {string} access_level
|
|
49
|
-
* @property {number} total_xp
|
|
50
49
|
* @property {BrandMethodLevels} brand_method_levels
|
|
51
|
-
* @property {BrandTotalXp} brand_total_xp
|
|
52
50
|
* @property {BrandTimePracticed} brand_minutes_practiced
|
|
53
51
|
* @property {BrandTimePracticed} brand_seconds_practiced
|
|
54
52
|
* @property {number|null} guitar_playing_since_year
|
|
@@ -982,9 +982,9 @@ function generateContentsMap(contents, playlistsContents) {
|
|
|
982
982
|
export async function getProgressRows({ brand = 'drumeo', limit = 8 } = {}) {
|
|
983
983
|
// TODO slice progress to a reasonable number, say 100
|
|
984
984
|
const methodCardPromise = getMethodCard(brand)
|
|
985
|
-
const [recentPlaylists,
|
|
985
|
+
const [recentPlaylists, nonPlaylistContentIds, userPinnedItem] = await Promise.all([
|
|
986
986
|
fetchUserPlaylists(brand, { sort: '-last_progress', limit: limit }),
|
|
987
|
-
getAllStartedOrCompleted({
|
|
987
|
+
getAllStartedOrCompleted({ brand: brand, limit }),
|
|
988
988
|
getUserPinnedItem(brand),
|
|
989
989
|
])
|
|
990
990
|
|
|
@@ -995,7 +995,6 @@ export async function getProgressRows({ brand = 'drumeo', limit = 8 } = {}) {
|
|
|
995
995
|
)
|
|
996
996
|
|
|
997
997
|
// todo post v2: refactor this once we migrate playlist progress tracking to new system
|
|
998
|
-
const nonPlaylistContentIds = Object.keys(progressContents)
|
|
999
998
|
if (userPinnedItem?.progressType === 'content') {
|
|
1000
999
|
nonPlaylistContentIds.push(userPinnedItem.id)
|
|
1001
1000
|
}
|
|
@@ -91,7 +91,8 @@ describe('Award Completion Flow - E2E Scenarios', () => {
|
|
|
91
91
|
definition: expect.objectContaining({
|
|
92
92
|
name: testAward.name,
|
|
93
93
|
badge: testAward.badge,
|
|
94
|
-
award: testAward.award
|
|
94
|
+
award: testAward.award,
|
|
95
|
+
content_type: expect.any(String)
|
|
95
96
|
}),
|
|
96
97
|
completionData: expect.objectContaining({
|
|
97
98
|
content_title: expect.any(String),
|