musora-content-services 2.158.2 → 2.159.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 +12 -0
- package/.github/workflows/automated-testing.yml +21 -1
- package/CHANGELOG.md +15 -0
- package/README.md +21 -2
- package/jest.config.js +1 -4
- package/jest.integration.config.js +6 -0
- package/jest.live.config.js +1 -5
- package/package.json +5 -2
- package/src/contentTypeConfig.js +8 -5
- package/src/index.d.ts +2 -6
- package/src/index.js +2 -6
- package/src/services/content-org/learning-paths.ts +44 -39
- package/src/services/contentAggregator.js +1 -1
- package/src/services/contentProgress.js +216 -207
- package/src/services/offline/progress.ts +107 -27
- package/src/services/sanity.js +55 -64
- package/src/services/sync/models/ContentProgress.ts +50 -34
- package/src/services/sync/repositories/content-progress.ts +105 -92
- package/test/{unit → integration}/awards/award-exclusion-handling.test.ts +2 -2
- package/test/integration/content-progress/__mocks__/mocks.ts +104 -0
- package/test/integration/content-progress/contentProgress.test.ts +335 -0
- package/test/integration/content-progress/e2eOfflineProgress.test.ts +352 -0
- package/test/integration/content-progress/e2eProgress.test.ts +612 -0
- package/test/integration/content-progress/getters.test.ts +334 -0
- package/test/integration/content-progress/helpers.test.ts +263 -0
- package/test/integration/content-progress/offlineContentProgress.test.ts +226 -0
- package/test/integration/forums.test.ts +209 -0
- package/test/integration/initializeTestDB.ts +80 -0
- package/test/{unit → integration}/sync/fetch.test.ts +1 -1
- package/test/{unit → integration}/sync/repositories/content-likes.test.ts +1 -1
- package/test/{unit → integration}/sync/repositories/practices.test.ts +1 -1
- package/test/{unit → integration}/sync/repositories/progress.test.ts +1 -1
- package/test/{unit → integration}/sync/repositories/user-award-progress.test.ts +1 -1
- package/test/{unit → integration}/sync/store/cross-user-protection.test.ts +2 -2
- package/test/{unit → integration}/sync/store/store-idb.test.ts +2 -2
- package/test/{unit → integration}/sync/store/store.test.ts +2 -2
- package/test/unit/content-progress/bubbleTrickle.test.ts +322 -0
- package/test/unit/content-progress/helpers.test.ts +329 -0
- package/test/unit/content-progress/navigateTo.test.ts +381 -0
- package/test/unit/contentMetaData.test.ts +58 -0
- package/tools/generate-index.cjs +6 -3
- package/test/SKIPPED_TESTS.md +0 -151
- package/test/integration/content.test.js +0 -107
- package/test/integration/contentProgress.test.js +0 -73
- package/test/integration/forum.test.js +0 -16
- package/test/integration/sanityQueryService.test.js +0 -681
- package/test/unit/contentProgress.test.ts +0 -81
- /package/test/{unit → integration}/awards/internal/image-utils.test.ts +0 -0
- /package/test/{unit → integration}/infrastructure/FetchRequestExecutor.test.ts +0 -0
- /package/test/{unit → integration}/notifications.test.ts +0 -0
- /package/test/{unit → integration}/sync/adapters/idb-errors.test.ts +0 -0
- /package/test/{unit → integration}/sync/adapters/sqlite-errors.test.ts +0 -0
- /package/test/{unit → integration}/sync/repositories/user-award-progress.static.test.ts +0 -0
- /package/test/{unit → integration}/userActivity.test.ts +0 -0
|
@@ -1,20 +1,13 @@
|
|
|
1
1
|
import BaseModel from './Base'
|
|
2
2
|
import { SYNC_TABLES } from '../schema'
|
|
3
|
-
import {
|
|
4
|
-
positiveInt,
|
|
5
|
-
nullableUint16,
|
|
6
|
-
uint32,
|
|
7
|
-
string,
|
|
8
|
-
percent,
|
|
9
|
-
mediumint,
|
|
10
|
-
enumValue,
|
|
11
|
-
} from '../errors/validators'
|
|
3
|
+
import { enumValue, mediumint, nullableUint16, percent, positiveInt, string, uint32 } from '../errors/validators'
|
|
12
4
|
|
|
13
5
|
export enum COLLECTION_TYPE {
|
|
14
6
|
SELF = 'self',
|
|
15
7
|
LEARNING_PATH = 'learning-path-v2',
|
|
16
8
|
PLAYLIST = 'playlist',
|
|
17
9
|
}
|
|
10
|
+
|
|
18
11
|
export const COLLECTION_ID_SELF = 0
|
|
19
12
|
|
|
20
13
|
export const PARENT_ID_TOP_LEVEL = 0
|
|
@@ -49,68 +42,85 @@ export default class ContentProgress extends BaseModel {
|
|
|
49
42
|
get content_id() {
|
|
50
43
|
return this._getRaw('content_id') as number
|
|
51
44
|
}
|
|
45
|
+
|
|
46
|
+
set content_id(value: number) {
|
|
47
|
+
this._setRaw('content_id', validators.content_id(value))
|
|
48
|
+
}
|
|
49
|
+
|
|
52
50
|
get content_brand() {
|
|
53
51
|
return this._getRaw('content_brand') as string
|
|
54
52
|
}
|
|
53
|
+
|
|
54
|
+
set content_brand(value: string) {
|
|
55
|
+
this._setRaw('content_brand', validators.content_brand(value))
|
|
56
|
+
}
|
|
57
|
+
|
|
55
58
|
get content_type() {
|
|
56
59
|
return this._getRaw('content_type') as string
|
|
57
60
|
}
|
|
61
|
+
|
|
62
|
+
set content_type(value: string) {
|
|
63
|
+
this._setRaw('content_type', validators.content_type(value))
|
|
64
|
+
}
|
|
65
|
+
|
|
58
66
|
get content_parent_id() {
|
|
59
67
|
return this._getRaw('content_parent_id') as number
|
|
60
68
|
}
|
|
69
|
+
|
|
70
|
+
set content_parent_id(value: number) {
|
|
71
|
+
this._setRaw('content_parent_id', validators.content_parent_id(value))
|
|
72
|
+
}
|
|
73
|
+
|
|
61
74
|
get state() {
|
|
62
75
|
return this._getRaw('state') as STATE
|
|
63
76
|
}
|
|
77
|
+
|
|
64
78
|
get progress_percent() {
|
|
65
79
|
return this._getRaw('progress_percent') as number
|
|
66
80
|
}
|
|
67
|
-
get collection_type() {
|
|
68
|
-
return this._getRaw('collection_type') as COLLECTION_TYPE
|
|
69
|
-
}
|
|
70
|
-
get collection_id() {
|
|
71
|
-
return this._getRaw('collection_id') as number
|
|
72
|
-
}
|
|
73
|
-
get resume_time_seconds() {
|
|
74
|
-
return this._getRaw('resume_time_seconds') as number | null
|
|
75
|
-
}
|
|
76
|
-
get last_interacted_a_la_carte() {
|
|
77
|
-
return this._getRaw('last_interacted_a_la_carte') as number
|
|
78
|
-
}
|
|
79
81
|
|
|
80
|
-
set content_id(value: number) {
|
|
81
|
-
this._setRaw('content_id', validators.content_id(value))
|
|
82
|
-
}
|
|
83
|
-
set content_brand(value: string) {
|
|
84
|
-
this._setRaw('content_brand', validators.content_brand(value))
|
|
85
|
-
}
|
|
86
|
-
set content_type(value: string) {
|
|
87
|
-
this._setRaw('content_type', validators.content_type(value))
|
|
88
|
-
}
|
|
89
|
-
set content_parent_id(value: number) {
|
|
90
|
-
this._setRaw('content_parent_id', validators.content_parent_id(value))
|
|
91
|
-
}
|
|
92
82
|
set progress_percent(value: number) {
|
|
93
83
|
const percent = validators.progress_percent(value, this.progress_percent)
|
|
94
84
|
|
|
95
85
|
this._setRaw('progress_percent', percent)
|
|
96
86
|
this._setRaw('state', percent === 100 ? STATE.COMPLETED : STATE.STARTED)
|
|
97
87
|
}
|
|
88
|
+
|
|
89
|
+
get collection_type() {
|
|
90
|
+
return this._getRaw('collection_type') as COLLECTION_TYPE
|
|
91
|
+
}
|
|
92
|
+
|
|
98
93
|
set collection_type(value: COLLECTION_TYPE) {
|
|
99
94
|
this._setRaw('collection_type', validators.collection_type(value))
|
|
100
95
|
}
|
|
96
|
+
|
|
97
|
+
get collection_id() {
|
|
98
|
+
return this._getRaw('collection_id') as number
|
|
99
|
+
}
|
|
100
|
+
|
|
101
101
|
set collection_id(value: number) {
|
|
102
102
|
this._setRaw('collection_id', validators.collection_id(value))
|
|
103
103
|
}
|
|
104
|
+
|
|
105
|
+
get resume_time_seconds() {
|
|
106
|
+
return this._getRaw('resume_time_seconds') as number | null
|
|
107
|
+
}
|
|
108
|
+
|
|
104
109
|
set resume_time_seconds(value: number | null) {
|
|
105
110
|
this._setRaw('resume_time_seconds', validators.resume_time_seconds(value))
|
|
106
111
|
}
|
|
112
|
+
|
|
113
|
+
get last_interacted_a_la_carte() {
|
|
114
|
+
return this._getRaw('last_interacted_a_la_carte') as number
|
|
115
|
+
}
|
|
116
|
+
|
|
107
117
|
set last_interacted_a_la_carte(value: number) {
|
|
108
118
|
this._setRaw('last_interacted_a_la_carte', value)
|
|
109
119
|
}
|
|
110
120
|
|
|
111
121
|
static generateId(
|
|
112
122
|
contentId: number,
|
|
113
|
-
collection: CollectionParameter | null
|
|
123
|
+
collection: CollectionParameter | null,
|
|
114
124
|
) {
|
|
115
125
|
validators.content_id(contentId)
|
|
116
126
|
|
|
@@ -121,4 +131,10 @@ export default class ContentProgress extends BaseModel {
|
|
|
121
131
|
|
|
122
132
|
return `${contentId}:${collection?.type || COLLECTION_TYPE.SELF}:${collection?.id || COLLECTION_ID_SELF}`
|
|
123
133
|
}
|
|
134
|
+
|
|
135
|
+
setProgressForceRegression(value: number) {
|
|
136
|
+
const validated = percent(value)
|
|
137
|
+
this._setRaw('progress_percent', validated)
|
|
138
|
+
this._setRaw('state', validated === 100 ? STATE.COMPLETED : STATE.STARTED)
|
|
139
|
+
}
|
|
124
140
|
}
|
|
@@ -1,5 +1,10 @@
|
|
|
1
|
-
import SyncRepository, {Q} from './base'
|
|
2
|
-
import ContentProgress, {
|
|
1
|
+
import SyncRepository, { Q } from './base'
|
|
2
|
+
import ContentProgress, {
|
|
3
|
+
COLLECTION_ID_SELF,
|
|
4
|
+
COLLECTION_TYPE,
|
|
5
|
+
CollectionParameter,
|
|
6
|
+
STATE,
|
|
7
|
+
} from '../models/ContentProgress'
|
|
3
8
|
|
|
4
9
|
interface MetadataParameter {
|
|
5
10
|
brand: string
|
|
@@ -9,13 +14,43 @@ interface MetadataParameter {
|
|
|
9
14
|
|
|
10
15
|
export default class ProgressRepository extends SyncRepository<ContentProgress> {
|
|
11
16
|
|
|
17
|
+
static collectionTypeFilter(
|
|
18
|
+
params: {
|
|
19
|
+
aLaCarte?: boolean;
|
|
20
|
+
learningPaths?: boolean
|
|
21
|
+
} = {}) {
|
|
22
|
+
let clauses: Q.Where[] = []
|
|
23
|
+
|
|
24
|
+
if (params.aLaCarte) {
|
|
25
|
+
clauses.push(
|
|
26
|
+
Q.and( // a-la-carte content that's been accessed directly
|
|
27
|
+
Q.where('collection_type', COLLECTION_TYPE.SELF),
|
|
28
|
+
Q.where('collection_id', COLLECTION_ID_SELF),
|
|
29
|
+
Q.where('last_interacted_a_la_carte', Q.notEq(null)),
|
|
30
|
+
),
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (params.learningPaths) {
|
|
35
|
+
clauses.push(
|
|
36
|
+
Q.and( // just parents
|
|
37
|
+
Q.where('collection_type', COLLECTION_TYPE.LEARNING_PATH),
|
|
38
|
+
Q.where('content_id', Q.eq(Q.column('collection_id'))),
|
|
39
|
+
),
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (clauses.length === 0) return
|
|
44
|
+
return Q.or(...clauses)
|
|
45
|
+
}
|
|
46
|
+
|
|
12
47
|
async started(
|
|
13
48
|
limit?: number,
|
|
14
49
|
opts: {
|
|
15
50
|
onlyIds?: boolean
|
|
16
51
|
include?: { aLaCarte?: boolean, learningPaths?: boolean }
|
|
17
|
-
} = {}
|
|
18
|
-
|
|
52
|
+
} = {},
|
|
53
|
+
) {
|
|
19
54
|
const results = await this.queryAll(...[
|
|
20
55
|
ProgressRepository.collectionTypeFilter(opts.include),
|
|
21
56
|
|
|
@@ -26,8 +61,8 @@ export default class ProgressRepository extends SyncRepository<ContentProgress>
|
|
|
26
61
|
].filter(Boolean) as Q.Clause[])
|
|
27
62
|
|
|
28
63
|
return opts.onlyIds
|
|
29
|
-
|
|
30
|
-
|
|
64
|
+
? results.data.map((r) => r.content_id)
|
|
65
|
+
: results.data
|
|
31
66
|
}
|
|
32
67
|
|
|
33
68
|
async completed(
|
|
@@ -35,7 +70,7 @@ export default class ProgressRepository extends SyncRepository<ContentProgress>
|
|
|
35
70
|
opts: {
|
|
36
71
|
onlyIds?: boolean
|
|
37
72
|
include?: { aLaCarte?: boolean, learningPaths?: boolean }
|
|
38
|
-
} = {}
|
|
73
|
+
} = {},
|
|
39
74
|
) {
|
|
40
75
|
const results = await this.queryAll(...[
|
|
41
76
|
ProgressRepository.collectionTypeFilter(opts.include),
|
|
@@ -55,7 +90,7 @@ export default class ProgressRepository extends SyncRepository<ContentProgress>
|
|
|
55
90
|
async completedByContentIds(contentIds: number[]) {
|
|
56
91
|
return this.queryAll(
|
|
57
92
|
Q.where('content_id', Q.oneOf(contentIds)),
|
|
58
|
-
Q.where('state', STATE.COMPLETED)
|
|
93
|
+
Q.where('state', STATE.COMPLETED),
|
|
59
94
|
)
|
|
60
95
|
}
|
|
61
96
|
|
|
@@ -63,59 +98,19 @@ export default class ProgressRepository extends SyncRepository<ContentProgress>
|
|
|
63
98
|
return this.queryAll(...this.startedOrCompletedClauses(opts))
|
|
64
99
|
}
|
|
65
100
|
|
|
66
|
-
private startedOrCompletedClauses(
|
|
67
|
-
opts: {
|
|
68
|
-
brand?: string | null
|
|
69
|
-
contentTypes?: string[] | null
|
|
70
|
-
parentId?: number | null
|
|
71
|
-
include?: { aLaCarte?: boolean, learningPaths?: boolean }
|
|
72
|
-
updatedAfter?: number
|
|
73
|
-
limit?: number
|
|
74
|
-
} = {}
|
|
75
|
-
) {
|
|
76
|
-
const clauses: Q.Clause[] = [
|
|
77
|
-
ProgressRepository.collectionTypeFilter(opts.include),
|
|
78
|
-
|
|
79
|
-
Q.or(Q.where('state', STATE.STARTED), Q.where('state', STATE.COMPLETED)),
|
|
80
|
-
Q.sortBy('updated_at', 'desc'),
|
|
81
|
-
].filter(Boolean) as Q.Clause[]
|
|
82
|
-
|
|
83
|
-
if (opts.updatedAfter) {
|
|
84
|
-
clauses.push(Q.where('updated_at', Q.gte(opts.updatedAfter)))
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
if (opts.brand) {
|
|
88
|
-
clauses.push(Q.where('content_brand', opts.brand))
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
if (opts.contentTypes) {
|
|
92
|
-
clauses.push(Q.where('content_type', Q.oneOf(opts.contentTypes)))
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
if (opts.parentId || opts.parentId === 0) {
|
|
96
|
-
clauses.push(Q.where('content_parent_id', opts.parentId))
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
if (opts.limit) {
|
|
100
|
-
clauses.push(Q.take(opts.limit))
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
return clauses
|
|
104
|
-
}
|
|
105
|
-
|
|
106
101
|
async mostRecentlyUpdatedId(contentIds: number[], collection: CollectionParameter | null = null) {
|
|
107
102
|
return this.queryOneId(
|
|
108
103
|
Q.where('content_id', Q.oneOf(contentIds)),
|
|
109
104
|
Q.where('collection_type', collection?.type ?? COLLECTION_TYPE.SELF),
|
|
110
105
|
Q.where('collection_id', collection?.id ?? COLLECTION_ID_SELF),
|
|
111
106
|
|
|
112
|
-
Q.sortBy('updated_at', 'desc')
|
|
107
|
+
Q.sortBy('updated_at', 'desc'),
|
|
113
108
|
)
|
|
114
109
|
}
|
|
115
110
|
|
|
116
111
|
async getOneProgressByContentId(
|
|
117
112
|
contentId: number,
|
|
118
|
-
collection: CollectionParameter | null = null
|
|
113
|
+
collection: CollectionParameter | null = null,
|
|
119
114
|
) {
|
|
120
115
|
const clauses = [
|
|
121
116
|
Q.where('content_id', contentId),
|
|
@@ -128,7 +123,7 @@ export default class ProgressRepository extends SyncRepository<ContentProgress>
|
|
|
128
123
|
|
|
129
124
|
async getSomeProgressByContentIds(
|
|
130
125
|
contentIds: number[],
|
|
131
|
-
collection: CollectionParameter | null = null
|
|
126
|
+
collection: CollectionParameter | null = null,
|
|
132
127
|
) {
|
|
133
128
|
const clauses = [
|
|
134
129
|
Q.where('content_id', Q.oneOf(contentIds)),
|
|
@@ -147,16 +142,16 @@ export default class ProgressRepository extends SyncRepository<ContentProgress>
|
|
|
147
142
|
Q.where('collection_type', COLLECTION_TYPE.SELF),
|
|
148
143
|
Q.where('collection_id', COLLECTION_ID_SELF),
|
|
149
144
|
Q.or(
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
)
|
|
145
|
+
Q.and(
|
|
146
|
+
Q.where('updated_at', Q.notEq(null)),
|
|
147
|
+
Q.where('last_interacted_a_la_carte', null),
|
|
148
|
+
),
|
|
149
|
+
Q.and(
|
|
150
|
+
Q.where('updated_at', Q.notEq(null)),
|
|
151
|
+
Q.where('last_interacted_a_la_carte', Q.notEq(null)),
|
|
152
|
+
Q.where('updated_at', Q.gt(Q.column('last_interacted_a_la_carte'))),
|
|
153
|
+
),
|
|
154
|
+
),
|
|
160
155
|
]
|
|
161
156
|
|
|
162
157
|
return await this.queryAll(...clauses)
|
|
@@ -172,7 +167,7 @@ export default class ProgressRepository extends SyncRepository<ContentProgress>
|
|
|
172
167
|
progressPct: number,
|
|
173
168
|
metadata: MetadataParameter,
|
|
174
169
|
resumeTime?: number,
|
|
175
|
-
{skipPush = false, accessedDirectly = true} = {}) {
|
|
170
|
+
{ skipPush = false, accessedDirectly = true } = {}) {
|
|
176
171
|
const id = ContentProgress.generateId(contentId, collection)
|
|
177
172
|
|
|
178
173
|
if (collection?.type === COLLECTION_TYPE.LEARNING_PATH) {
|
|
@@ -205,7 +200,7 @@ export default class ProgressRepository extends SyncRepository<ContentProgress>
|
|
|
205
200
|
// Emit event AFTER database write completes (don't let emit failures affect the result)
|
|
206
201
|
Promise.all([
|
|
207
202
|
import('../../progress-events'),
|
|
208
|
-
import('../../config')
|
|
203
|
+
import('../../config'),
|
|
209
204
|
]).then(([progressEventsModule, { globalConfig }]) => {
|
|
210
205
|
progressEventsModule.emitProgressSaved({
|
|
211
206
|
userId: Number(globalConfig.railcontentConfig?.userId) || 0,
|
|
@@ -216,7 +211,7 @@ export default class ProgressRepository extends SyncRepository<ContentProgress>
|
|
|
216
211
|
collectionType: collection?.type ?? COLLECTION_TYPE.SELF,
|
|
217
212
|
collectionId: collection?.id ?? COLLECTION_ID_SELF,
|
|
218
213
|
resumeTimeSeconds: resumeTime ?? null,
|
|
219
|
-
timestamp: Date.now()
|
|
214
|
+
timestamp: Date.now(),
|
|
220
215
|
})
|
|
221
216
|
}).catch(error => {
|
|
222
217
|
console.error('Failed to emit progress saved event:', error)
|
|
@@ -229,7 +224,11 @@ export default class ProgressRepository extends SyncRepository<ContentProgress>
|
|
|
229
224
|
contentProgresses: Record<string, number>, // Accept plain object
|
|
230
225
|
collection: CollectionParameter | null,
|
|
231
226
|
metadata: Record<string, MetadataParameter>,
|
|
232
|
-
{ skipPush = false, accessedDirectly = true
|
|
227
|
+
{ skipPush = false, accessedDirectly = true, allowRegression = false }: {
|
|
228
|
+
skipPush?: boolean;
|
|
229
|
+
accessedDirectly?: boolean,
|
|
230
|
+
allowRegression?: boolean
|
|
231
|
+
} = {},
|
|
233
232
|
) {
|
|
234
233
|
if (collection?.type === COLLECTION_TYPE.LEARNING_PATH) {
|
|
235
234
|
accessedDirectly = false
|
|
@@ -243,7 +242,11 @@ export default class ProgressRepository extends SyncRepository<ContentProgress>
|
|
|
243
242
|
r.collection_type = collection?.type ?? COLLECTION_TYPE.SELF
|
|
244
243
|
r.collection_id = collection?.id ?? COLLECTION_ID_SELF
|
|
245
244
|
|
|
246
|
-
|
|
245
|
+
if (allowRegression) {
|
|
246
|
+
r.setProgressForceRegression(progressPct)
|
|
247
|
+
} else {
|
|
248
|
+
r.progress_percent = progressPct
|
|
249
|
+
}
|
|
247
250
|
|
|
248
251
|
r.content_brand = metadata[contentId].brand
|
|
249
252
|
r.content_type = metadata[contentId].type
|
|
@@ -253,49 +256,59 @@ export default class ProgressRepository extends SyncRepository<ContentProgress>
|
|
|
253
256
|
r.last_interacted_a_la_carte = r.updated_at
|
|
254
257
|
}
|
|
255
258
|
},
|
|
256
|
-
])
|
|
259
|
+
]),
|
|
257
260
|
)
|
|
258
261
|
return await this.upsertSome(data, { skipPush })
|
|
259
262
|
|
|
260
263
|
//todo add event emitting for bulk updates?
|
|
261
264
|
}
|
|
262
265
|
|
|
263
|
-
eraseProgress(contentId: number, collection: CollectionParameter | null, {skipPush = false} = {}) {
|
|
266
|
+
eraseProgress(contentId: number, collection: CollectionParameter | null, { skipPush = false } = {}) {
|
|
264
267
|
return this.deleteOne(ContentProgress.generateId(contentId, collection), { skipPush })
|
|
265
268
|
}
|
|
266
269
|
|
|
267
|
-
eraseProgressMany(contentIds: number[], collection: CollectionParameter | null, {skipPush = false} = {}) {
|
|
270
|
+
eraseProgressMany(contentIds: number[], collection: CollectionParameter | null, { skipPush = false } = {}) {
|
|
268
271
|
const ids = contentIds.map((id) => ContentProgress.generateId(id, collection))
|
|
269
272
|
return this.deleteSome(ids, { skipPush })
|
|
270
273
|
}
|
|
271
274
|
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
275
|
+
private startedOrCompletedClauses(
|
|
276
|
+
opts: {
|
|
277
|
+
brand?: string | null
|
|
278
|
+
contentTypes?: string[] | null
|
|
279
|
+
parentId?: number | null
|
|
280
|
+
include?: { aLaCarte?: boolean, learningPaths?: boolean }
|
|
281
|
+
updatedAfter?: number
|
|
282
|
+
limit?: number
|
|
283
|
+
} = {},
|
|
284
|
+
) {
|
|
285
|
+
const clauses: Q.Clause[] = [
|
|
286
|
+
ProgressRepository.collectionTypeFilter(opts.include),
|
|
278
287
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
),
|
|
286
|
-
)
|
|
288
|
+
Q.or(Q.where('state', STATE.STARTED), Q.where('state', STATE.COMPLETED)),
|
|
289
|
+
Q.sortBy('updated_at', 'desc'),
|
|
290
|
+
].filter(Boolean) as Q.Clause[]
|
|
291
|
+
|
|
292
|
+
if (opts.updatedAfter) {
|
|
293
|
+
clauses.push(Q.where('updated_at', Q.gte(opts.updatedAfter)))
|
|
287
294
|
}
|
|
288
295
|
|
|
289
|
-
if (
|
|
290
|
-
clauses.push(
|
|
291
|
-
Q.and( // just parents
|
|
292
|
-
Q.where('collection_type', COLLECTION_TYPE.LEARNING_PATH),
|
|
293
|
-
Q.where('content_id', Q.eq(Q.column('collection_id')))
|
|
294
|
-
)
|
|
295
|
-
)
|
|
296
|
+
if (opts.brand) {
|
|
297
|
+
clauses.push(Q.where('content_brand', opts.brand))
|
|
296
298
|
}
|
|
297
299
|
|
|
298
|
-
if (
|
|
299
|
-
|
|
300
|
+
if (opts.contentTypes) {
|
|
301
|
+
clauses.push(Q.where('content_type', Q.oneOf(opts.contentTypes)))
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (opts.parentId || opts.parentId === 0) {
|
|
305
|
+
clauses.push(Q.where('content_parent_id', opts.parentId))
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (opts.limit) {
|
|
309
|
+
clauses.push(Q.take(opts.limit))
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
return clauses
|
|
300
313
|
}
|
|
301
314
|
}
|
|
@@ -3,8 +3,8 @@ import { awardEvents } from '../../../src/services/awards/internal/award-events.
|
|
|
3
3
|
import { mockAwardDefinitions, getAwardByContentId } from '../../mockData/award-definitions.js'
|
|
4
4
|
import { globalConfig } from '../../../src/services/config.js'
|
|
5
5
|
import { LocalStorageMock } from '../../localStorageMock.js'
|
|
6
|
-
import { setupDefaultMocks, setupAwardEventListeners } from '
|
|
7
|
-
import { mockCompletionStates, mockAllCompleted, mockNoneCompleted } from '
|
|
6
|
+
import { setupDefaultMocks, setupAwardEventListeners } from '../../unit/awards/helpers/index'
|
|
7
|
+
import { mockCompletionStates, mockAllCompleted, mockNoneCompleted } from '../../unit/awards/helpers/completion-mock'
|
|
8
8
|
|
|
9
9
|
jest.mock('../../../src/services/sanity.js', () => ({
|
|
10
10
|
...jest.requireActual('../../../src/services/sanity'),
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
export const mockContentProgressObserver = () => ({
|
|
2
|
+
contentProgressObserver: {
|
|
3
|
+
start: jest.fn().mockResolvedValue(undefined),
|
|
4
|
+
stop: jest.fn(),
|
|
5
|
+
},
|
|
6
|
+
})
|
|
7
|
+
|
|
8
|
+
export const mockLearningPaths = () => ({
|
|
9
|
+
getDailySession: jest.fn().mockResolvedValue(null),
|
|
10
|
+
onLearningPathCompletedActions: jest.fn().mockResolvedValue(undefined),
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
export const mockProgressEvents = () => ({
|
|
14
|
+
emitProgressSaved: jest.fn(),
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
export type HierarchyTreeNode = {
|
|
18
|
+
id: number
|
|
19
|
+
type?: string
|
|
20
|
+
brand?: string
|
|
21
|
+
children?: HierarchyTreeNode[]
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
type HierarchyLookups = {
|
|
25
|
+
topLevelId: number
|
|
26
|
+
parents: Record<number, number>
|
|
27
|
+
children: Record<number, number[]>
|
|
28
|
+
metadata: Record<number, { type: string; brand: string; parent_id: number }>
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const LP_TYPE = 'learning-path-v2'
|
|
32
|
+
|
|
33
|
+
const ALC_KEY = 'alc'
|
|
34
|
+
const LP_KEY = 'lp'
|
|
35
|
+
|
|
36
|
+
let hierarchiesByKey: Record<string, Record<number, HierarchyLookups>> = {}
|
|
37
|
+
let topByKey: Record<string, Record<number, number>> = {}
|
|
38
|
+
|
|
39
|
+
function keyFor(collection?: { type?: string } | null): string {
|
|
40
|
+
return collection?.type === LP_TYPE ? LP_KEY : ALC_KEY
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function setHierarchy(tree: HierarchyTreeNode, options?: { lp?: boolean }) {
|
|
44
|
+
const key = options?.lp ? LP_KEY : ALC_KEY
|
|
45
|
+
hierarchiesByKey[key] ??= {}
|
|
46
|
+
topByKey[key] ??= {}
|
|
47
|
+
hierarchiesByKey[key][tree.id] = treeToLookups(tree)
|
|
48
|
+
registerIds(tree, tree.id, topByKey[key])
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function clearHierarchies() {
|
|
52
|
+
hierarchiesByKey = {}
|
|
53
|
+
topByKey = {}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function treeToLookups(root: HierarchyTreeNode): HierarchyLookups {
|
|
57
|
+
const parents: Record<number, number> = {}
|
|
58
|
+
const children: Record<number, number[]> = {}
|
|
59
|
+
const metadata: Record<number, { type: string; brand: string; parent_id: number }> = {}
|
|
60
|
+
|
|
61
|
+
function walk(node: HierarchyTreeNode, parentId: number) {
|
|
62
|
+
const childIds = (node.children ?? []).map(c => c.id)
|
|
63
|
+
metadata[node.id] = {
|
|
64
|
+
type: node.type ?? 'lesson',
|
|
65
|
+
brand: node.brand ?? 'drumeo',
|
|
66
|
+
parent_id: parentId,
|
|
67
|
+
}
|
|
68
|
+
if (parentId) parents[node.id] = parentId
|
|
69
|
+
if (childIds.length > 0) children[node.id] = childIds
|
|
70
|
+
for (const c of node.children ?? []) walk(c, node.id)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
walk(root, 0)
|
|
74
|
+
|
|
75
|
+
return { topLevelId: root.id, parents, children, metadata }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function registerIds(node: HierarchyTreeNode, topId: number, target: Record<number, number>) {
|
|
79
|
+
target[node.id] = topId
|
|
80
|
+
for (const c of node.children ?? []) registerIds(c, topId, target)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function lookupFor(contentId: number, collection?: any): HierarchyLookups {
|
|
84
|
+
const key = keyFor(collection)
|
|
85
|
+
const tops = topByKey[key] ?? {}
|
|
86
|
+
const hierarchies = hierarchiesByKey[key] ?? {}
|
|
87
|
+
const topId = tops[contentId] ?? (key === LP_KEY ? collection?.id : contentId)
|
|
88
|
+
return hierarchies[topId] ?? {
|
|
89
|
+
topLevelId: contentId,
|
|
90
|
+
parents: {},
|
|
91
|
+
children: {},
|
|
92
|
+
metadata: { [contentId]: { brand: 'drumeo', type: 'lesson', parent_id: 0 } },
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export const mockSanity = () => ({
|
|
97
|
+
getHierarchy: jest.fn((contentId: number, collection?: any) =>
|
|
98
|
+
Promise.resolve(lookupFor(contentId, collection)),
|
|
99
|
+
),
|
|
100
|
+
getHierarchies: jest.fn((contentIds: number[], collection?: any) =>
|
|
101
|
+
Promise.resolve(Object.fromEntries(contentIds.map(id => [id, lookupFor(id, collection)]))),
|
|
102
|
+
),
|
|
103
|
+
getSanityDate: jest.fn((date: Date) => date.toISOString()),
|
|
104
|
+
})
|