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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "musora-content-services",
3
- "version": "2.104.9",
3
+ "version": "2.106.0",
4
4
  "description": "A package for Musoras content services ",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -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.practice_minutes` - Total practice time for this award
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.getAll()
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 completionData = enhanceCompletionData(progress.completion_data)
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: new Date(progress.completed_at * 1000).toISOString(),
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 == 'content-award'] {
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.now(),
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.completed_at
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,
@@ -65,6 +65,7 @@ export interface AwardCallbackPayload {
65
65
  awardId: string
66
66
  name: string
67
67
  badge: string
68
+ contentType: string
68
69
  completed_at: string
69
70
  isCompleted: boolean
70
71
  completion_data: AwardCompletionData
@@ -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).then((r) => r.data.map((id) => parseInt(id)))
320
+ return db.contentProgress.startedIds(limit)
320
321
  }
321
322
 
322
323
  export async function getAllCompleted(limit = null) {
323
- return db.contentProgress.completedIds(limit).then((r) => r.data.map((id) => parseInt(id)))
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
- const agoInSeconds = Math.floor(Date.now() / 1000) - 60 * 24 * 60 * 60 // 60 days in seconds
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 db.contentProgress.startedOrCompleted({ brand: brand }).then((r) => {
382
- return Object.fromEntries(r.data.map((p) => [p.content_id, p.progress_percent]))
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
@@ -98,7 +98,7 @@ export async function getMethodCard(brand) {
98
98
  action = nextLesson
99
99
  ? getMethodActionCTA(nextLesson)
100
100
  : {
101
- type: 'method',
101
+ type: 'method-complete',
102
102
  brand,
103
103
  }
104
104
  }
@@ -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
- this._setRaw('content_id', value)
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
- this._setRaw('progress_percent', Math.min(100, Math.max(0, value)))
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
- this._setRaw('collection_type', value)
77
+ // enum collection_type
78
+ this._setRaw('collection_type', throwIfInvalidEnumValue(value, COLLECTION_TYPE))
63
79
  }
64
80
  set collection_id(value: number) {
65
- this._setRaw('collection_id', value)
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
- this._setRaw('resume_time_seconds', value !== null ? Math.max(0, value) : null)
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
- this._setRaw('manual_id', value)
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
- this._setRaw('content_id', value)
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
- this._setRaw('auto', value)
68
+ // tinyint(1)
69
+ this._setRaw('auto', throwIfNotBoolean(value))
56
70
  }
57
71
  set duration_seconds(value: number) {
58
- this._setRaw('duration_seconds', value)
72
+ throwIfNotNumber(value)
73
+ this._setRaw('duration_seconds', throwIfOutsideRange(value, 0, 59999))
59
74
  }
60
75
  set title(value: string | null) {
61
- this._setRaw('title', value)
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
- this._setRaw('thumbnail_url', value)
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
- this._setRaw('category_id', value)
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
- this._setRaw('instrument_id', value)
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
- this._setRaw('notes', value)
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: number | null
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 number | null
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
- this._setRaw('award_id', value)
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
- this._setRaw('progress_percentage', value)
50
+ // int
51
+ throwIfNotNumber(value)
52
+ this._setRaw('progress_percentage', throwIfOutsideRange(value, 0, 100))
42
53
  }
43
54
 
44
- set completed_at(value: number | null) {
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.queryAllIds(...[
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.queryAllIds(...[
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: number | null
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: number | null }): Date | null {
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?: number | null
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.now(),
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', isOptional: true, isIndexed: true },
25
- { name: 'collection_id', type: 'number', isOptional: true, isIndexed: true },
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: 'number', isOptional: true, isIndexed: true },
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, progressContents, userPinnedItem] = await Promise.all([
985
+ const [recentPlaylists, nonPlaylistContentIds, userPinnedItem] = await Promise.all([
986
986
  fetchUserPlaylists(brand, { sort: '-last_progress', limit: limit }),
987
- getAllStartedOrCompleted({ onlyIds: false, brand: brand, limit }),
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),
@@ -1,9 +0,0 @@
1
- {
2
- "permissions": {
3
- "allow": [
4
- "Bash(rg:*)",
5
- "Bash(npm run lint:*)"
6
- ],
7
- "deny": []
8
- }
9
- }