musora-content-services 2.162.1 → 2.163.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.
@@ -0,0 +1,53 @@
1
+ ---
2
+ date: 2026-05-19
3
+ branch: refactor/content-progress-ts-part-2
4
+ pr: https://github.com/railroadmedia/musora-content-services/pull/978
5
+ status: open
6
+ tags: [[chore]]
7
+ ---
8
+
9
+ # Split progress module into typed sub-modules with Progress namespace
10
+
11
+ ## Context
12
+
13
+ `src/services/progress/index.ts` started as a flat file during the initial migration of `contentProgress.js` to TypeScript. As the migration continued it became clear the file would grow significantly, mixing query helpers, read operations, collection queries, and shared types. Naming also conflicted with the legacy JS exports still living in `contentProgress.js` during the transition.
14
+
15
+ ## Decision
16
+
17
+ Split `progress/index.ts` into focused sub-modules:
18
+
19
+ - `state.ts` — point queries for progress state, playback position, last interacted
20
+ - `collections.ts` — bulk queries (allStarted, allCompleted, allStartedOrCompleted)
21
+ - `types.ts` — shared interfaces (GetAllQueryOptions, QueryMetadata, StartedOrCompletedOptions)
22
+ - `internal/queries.ts` — private DB helpers (getById, getByIds, getByRecordIds)
23
+
24
+ `index.ts` becomes a 5-line file that spreads both modules into a `Progress` namespace object:
25
+
26
+ ```ts
27
+ export const Progress = { ...state, ...collections }
28
+ ```
29
+
30
+ **Naming convention: nouns for reads, verbs for writes.**
31
+ Query functions drop their `get`/`find`/`getAll` prefixes since the namespace provides context (`Progress.state`, `Progress.allStarted`). Write operations not yet migrated will keep verb names when they land (`Progress.recordWatchSession`, `Progress.markCompleted`).
32
+
33
+ `resumeTimeByIds` was renamed to `playbackPositionByIds` — `resumeTime` read as a verb phrase in camelCase and was semantically ambiguous.
34
+
35
+ `navigate-to.ts` updated to consume `Progress.*` instead of named imports from the old flat index.
36
+
37
+ ## Alternatives Considered
38
+
39
+ **Drop `get` prefix only (standalone names):** Rejected because `findIncompleteLesson` has a `find` prefix with semantic meaning (sync local search, not a DB fetch). Mixing bare nouns with `find*` names produces an inconsistent surface. The namespace approach sidesteps the verb question entirely.
40
+
41
+ **Nested read/write namespace (`Progress.get.*` / `Progress.set.*`):** Cleaner separation on paper but adds nesting for what is currently a read-only module. The simpler convention (nouns=reads, verbs=writes in flat namespace) was chosen to avoid premature structure.
42
+
43
+ ## Process Notes
44
+
45
+ - `getByIds` and `getById` in `internal/queries.ts` originally had `collection` as the second parameter. The user's linter moved it to last as an optional param — this required updating all call sites in `state.ts` and cascading arg-order fixes in navigate-to.ts and tests.
46
+ - LSP diagnostics repeatedly fired false positives after the param reorder, claiming `string` was not assignable to `CollectionParameter`. `npx tsc --noEmit` was consistently the source of truth — all those diagnostics were stale cache.
47
+ - `findIncompleteLesson` param order was changed by the linter to `(progressOnItems, contentType, currentContentId?)` — both `navigate-to.ts` and the test file required arg-order updates.
48
+
49
+ ## Consequences
50
+
51
+ - External callers import `Progress` and call `Progress.state(id)`, `Progress.allStarted()`, etc. — no ambiguity with `contentProgress.js` exports during the remaining migration
52
+ - Write operations remain in `contentProgress.js` until a follow-up PR migrates them into the `Progress` namespace with verb names
53
+ - The `internal/` directory convention signals private helpers not intended for direct import outside the progress module
@@ -1,41 +1,12 @@
1
1
  {
2
2
  "permissions": {
3
3
  "allow": [
4
- "Bash(npx jest *)",
5
- "Bash(npx tsc *)",
6
- "Skill(counselors)",
7
- "Bash(counselors ls *)",
8
- "Bash(counselors groups *)",
9
- "Bash(counselors run *)",
4
+ "Bash(rg:*)",
5
+ "Bash(npm run lint:*)",
6
+ "Bash(ls:*)",
10
7
  "Bash(npm test *)",
11
- "Bash(gh pr *)",
12
- "Bash(gh api *)",
13
- "Bash(mkdir -p /tmp/pr-review-v2)",
14
- "Read(//tmp/pr-review-v2/**)",
15
- "Bash(cat /home/alesevero/railenvironment/applications/musora-content-services/AGENTS.md)",
16
- "Bash(echo \"no AGENTS.md\")",
17
- "Bash(echo \"exit=$?\")",
18
- "Bash(git checkout *)",
19
- "Skill(pr)",
20
- "Skill(create-decision)"
21
- ]
22
- },
23
- "model": "sonnet",
24
- "effortLevel": "medium",
25
- "enabledMcpjsonServers": [
26
- "atlassian",
27
- "figma",
28
- "google-workspace",
29
- "snowflake",
30
- "aws",
31
- "hex",
32
- "sanity",
33
- "mysql",
34
- "slack",
35
- "langfuse",
36
- "chrome-devtools",
37
- "railway",
38
- "github",
39
- "asana"
40
- ]
8
+ "Bash(npx jest *)"
9
+ ],
10
+ "deny": []
11
+ }
41
12
  }
package/CHANGELOG.md CHANGED
@@ -2,6 +2,19 @@
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.163.0](https://github.com/railroadmedia/musora-content-services/compare/v2.162.1...v2.163.0) (2026-06-04)
6
+
7
+
8
+ ### Features
9
+
10
+ * add need access logic branch for LPs ([#993](https://github.com/railroadmedia/musora-content-services/issues/993)) ([1d935aa](https://github.com/railroadmedia/musora-content-services/commit/1d935aaf147b45cd01bc1d438424f5322991aa69))
11
+ * **TP-1195:** optimize live event loading ([#971](https://github.com/railroadmedia/musora-content-services/issues/971)) ([8daa616](https://github.com/railroadmedia/musora-content-services/commit/8daa6160c5bc1ee005093719beef1dd9e83ac3c8))
12
+
13
+
14
+ ### Bug Fixes
15
+
16
+ * dont set resume-time to null ([#992](https://github.com/railroadmedia/musora-content-services/issues/992)) ([d7f0f3e](https://github.com/railroadmedia/musora-content-services/commit/d7f0f3e6aab53dcb332a9c149dd0a33f95bc1d75))
17
+
5
18
  ### [2.162.1](https://github.com/railroadmedia/musora-content-services/compare/v2.162.0...v2.162.1) (2026-06-04)
6
19
 
7
20
  ## [2.162.0](https://github.com/railroadmedia/musora-content-services/compare/v2.161.4...v2.162.0) (2026-06-03)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "musora-content-services",
3
- "version": "2.162.1",
3
+ "version": "2.163.0",
4
4
  "description": "A package for Musoras content services ",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -695,7 +695,9 @@ export let contentTypeConfig = {
695
695
  }`,
696
696
  ],
697
697
  'new-and-scheduled': {
698
- fields: ['show_in_new_feed', isLiveField()],
698
+ fields: [
699
+ 'show_in_new_feed',
700
+ ],
699
701
  includeChildFields: true,
700
702
  },
701
703
  }
@@ -212,7 +212,7 @@ export async function navigateToParallelWithProgress() {
212
212
 
213
213
  const items = navigateRows as ContentWithNavAndProgress[]
214
214
  const decorators: FieldDecoratorAsync<ContentWithNavAndProgress>[] = [
215
- navigateToDecorator as FieldDecoratorAsync<ContentWithNavAndProgress>,
215
+ navigateToDecorator() as FieldDecoratorAsync<ContentWithNavAndProgress>,
216
216
  {
217
217
  field: 'progress_percent',
218
218
  compute: (item) => fetchProgress(item.id),
@@ -1,15 +1,10 @@
1
1
  import { decorateAllAsync, type Decoratable, type FieldDecoratorAsync } from './base'
2
- import {
3
- findIncompleteLesson,
4
- getLastInteractedOf,
5
- getProgressState,
6
- getProgressStateByIds,
7
- } from '../../../services/contentProgress.js'
8
2
  import {
9
3
  COLLECTION_TYPE,
10
4
  CollectionParameter,
11
5
  STATE,
12
6
  } from '../../../services/sync/models/ContentProgress'
7
+ import { Progress } from '@/services/progress'
13
8
 
14
9
  export const NAVIGATE_TO_FIELD = 'navigateTo' as const
15
10
 
@@ -18,7 +13,7 @@ const NAVIGABLE_TYPES = [
18
13
  'guided-course',
19
14
  'course-collection',
20
15
  'song-tutorial',
21
- 'learning-path-v2',
16
+ COLLECTION_TYPE.LEARNING_PATH,
22
17
  'skill-pack',
23
18
  ] as const
24
19
 
@@ -68,38 +63,66 @@ function buildNavigateTo(
68
63
  }
69
64
  }
70
65
 
71
- async function computeNavigateTo(content: NavigateToDecoratable): Promise<NavigateTo | null> {
66
+ interface NavigateContext {
67
+ states: Map<number, string>
68
+ }
69
+
70
+ async function prefetchStates(items: NavigateToDecoratable[]): Promise<NavigateContext> {
71
+ const ids = new Set<number>()
72
+ for (const item of items) {
73
+ if (!item || !NAVIGABLE_TYPES.includes(item.type as (typeof NAVIGABLE_TYPES)[number])) continue
74
+ ids.add(item.id)
75
+ for (const child of item.children ?? []) {
76
+ ids.add(child.id)
77
+ if (TWO_DEPTH_TYPES.includes(item.type)) {
78
+ for (const grandchild of child.children ?? []) {
79
+ ids.add(grandchild.id)
80
+ }
81
+ }
82
+ }
83
+ }
84
+ if (ids.size === 0) return { states: new Map() }
85
+ const states = await Progress.stateByIds(Array.from(ids))
86
+ return { states }
87
+ }
88
+
89
+ async function computeNavigateTo(
90
+ content: NavigateToDecoratable,
91
+ ctx?: NavigateContext
92
+ ): Promise<NavigateTo | null> {
72
93
  if (!NAVIGABLE_TYPES.includes(content.type as (typeof NAVIGABLE_TYPES)[number])) return null
73
94
 
74
95
  const children = content.children
75
96
  if (!children || children.length === 0) return null
76
97
 
77
- const contentState = await getProgressState(content.id)
98
+ const contentState = ctx?.states.get(content.id) ?? (await Progress.state(content.id))
78
99
  if (contentState !== STATE.STARTED) {
79
100
  const firstChild = children[0]
80
101
  const childNav = TWO_DEPTH_TYPES.includes(content.type)
81
- ? await computeNavigateTo(firstChild)
102
+ ? await computeNavigateTo(firstChild, ctx)
82
103
  : null
83
104
  return buildNavigateTo(firstChild, childNav)
84
105
  }
85
106
 
86
107
  const childrenIds = children.map((c) => c.id)
87
108
  const childrenById = new Map(children.map((c) => [c.id, c]))
88
- const childrenStates = (await getProgressStateByIds(childrenIds)) as Map<number, STATE>
89
- const lastInteractedId = (await getLastInteractedOf(childrenIds)) as number
109
+ const childrenStates = ctx
110
+ ? new Map(childrenIds.map((id) => [id, ctx.states.get(id) ?? '']))
111
+ : await Progress.stateByIds(childrenIds)
112
+ const lastInteractedId = await Progress.lastInteractedOf(childrenIds)
90
113
 
91
114
  if (COURSE_FLOW_TYPES.includes(content.type)) {
92
115
  const lastInteractedStatus = childrenStates.get(lastInteractedId)
93
116
  const targetId =
94
117
  lastInteractedStatus === STATE.STARTED
95
118
  ? lastInteractedId
96
- : findIncompleteLesson(childrenStates, lastInteractedId, content.type)
119
+ : Progress.incompleteLesson(childrenStates, content.type, lastInteractedId)
97
120
  const target = childrenById.get(targetId)
98
121
  return target ? buildNavigateTo(target) : null
99
122
  }
100
123
 
101
124
  if (GUIDED_FLOW_TYPES.includes(content.type)) {
102
- const targetId = findIncompleteLesson(childrenStates, lastInteractedId, content.type)
125
+ const targetId = Progress.incompleteLesson(childrenStates, content.type, lastInteractedId)
103
126
  const target = childrenById.get(targetId)
104
127
  return target ? buildNavigateTo(target) : null
105
128
  }
@@ -107,21 +130,21 @@ async function computeNavigateTo(content: NavigateToDecoratable): Promise<Naviga
107
130
  if (TWO_DEPTH_TYPES.includes(content.type)) {
108
131
  const lastChild = childrenById.get(lastInteractedId)
109
132
  if (!lastChild) return null
110
- const childNav = await computeNavigateTo(lastChild)
133
+ const childNav = await computeNavigateTo(lastChild, ctx)
111
134
  return buildNavigateTo(lastChild, childNav)
112
135
  }
113
136
 
114
137
  return null
115
138
  }
116
139
 
117
- export const navigateToDecorator: FieldDecoratorAsync<
118
- NavigateToDecoratable,
119
- typeof NAVIGATE_TO_FIELD,
120
- NavigateTo | null
121
- > = {
122
- field: NAVIGATE_TO_FIELD,
123
- compute: computeNavigateTo,
124
- recurse: false,
140
+ export function navigateToDecorator(
141
+ ctx?: NavigateContext
142
+ ): FieldDecoratorAsync<NavigateToDecoratable, typeof NAVIGATE_TO_FIELD, NavigateTo | null> {
143
+ return {
144
+ field: NAVIGATE_TO_FIELD,
145
+ compute: (item) => computeNavigateTo(item, ctx),
146
+ recurse: false,
147
+ }
125
148
  }
126
149
 
127
150
  export function decorateNavigateTo<T extends NavigateToDecoratable>(
@@ -130,10 +153,12 @@ export function decorateNavigateTo<T extends NavigateToDecoratable>(
130
153
  export function decorateNavigateTo<T extends NavigateToDecoratable>(
131
154
  items: T
132
155
  ): Promise<WithNavigateTo<T>>
133
- export function decorateNavigateTo<T extends NavigateToDecoratable>(
156
+ export async function decorateNavigateTo<T extends NavigateToDecoratable>(
134
157
  items: T | T[]
135
158
  ): Promise<WithNavigateTo<T> | WithNavigateTo<T>[]> {
136
- return decorateAllAsync(items as NavigateToDecoratable, [navigateToDecorator]) as Promise<
159
+ const list = Array.isArray(items) ? items : [items]
160
+ const ctx = await prefetchStates(list)
161
+ return decorateAllAsync(items as NavigateToDecoratable, [navigateToDecorator(ctx)]) as Promise<
137
162
  WithNavigateTo<T> | WithNavigateTo<T>[]
138
163
  >
139
164
  }
@@ -222,7 +222,7 @@ export async function resetAllLearningPaths() {
222
222
  * @param {number} learningPathId - The learning path ID
223
223
  * @returns {Promise<Object>} Learning path with enriched lesson data
224
224
  */
225
- export async function getEnrichedLearningPath(learningPathId) {
225
+ export async function getEnrichedLearningPath(learningPathId: number) {
226
226
  let response = (await addContextToLearningPaths(
227
227
  fetchByRailContentId,
228
228
  learningPathId,
@@ -841,7 +841,7 @@ export function filterOutLearningPathsForDuplication(progresses, collection) {
841
841
 
842
842
  export async function duplicateProgressForIds(entries) {
843
843
  return Promise.all(Object.entries(entries).map(([id, pct]) => {
844
- return saveContentProgress(parseInt(id), null, pct, null, { skipPush: true, accessedDirectly: false })
844
+ return saveContentProgress(parseInt(id), null, pct, undefined, { skipPush: true, accessedDirectly: false })
845
845
  }))
846
846
  }
847
847
 
@@ -19,6 +19,7 @@ import {
19
19
  } from './permissions'
20
20
  import {arrayToRawRepresentation, arrayToStringRepresentation} from '../../filterBuilder.js'
21
21
  import {basicMembershipTier, plusMembershipTier} from "../../contentTypeConfig";
22
+ import { COLLECTION_TYPE } from '../sync/models/ContentProgress'
22
23
 
23
24
  /**
24
25
  * V2 Permissions Adapter for the new permissions system.
@@ -55,6 +56,9 @@ export class PermissionsV2Adapter extends PermissionsAdapter {
55
56
 
56
57
  // Content with no permissions is accessible to all
57
58
  if (contentPermissions.size === 0) {
59
+ if (content?.type === COLLECTION_TYPE.LEARNING_PATH && content?.children?.length) {
60
+ return content.children.every(child => child.need_access)
61
+ }
58
62
  return false
59
63
  }
60
64
 
File without changes
@@ -0,0 +1,33 @@
1
+ import { db } from '../sync'
2
+ import { ProgressQueryOptions, StartedOrCompletedOptions } from './types'
3
+
4
+ const SIXTY_DAYS_IN_SECONDS = 60 * 24 * 60 * 60
5
+
6
+ const defaultQueryOptions: ProgressQueryOptions = {
7
+ onlyIds: true,
8
+ include: {
9
+ aLaCarte: true,
10
+ learningPaths: false,
11
+ },
12
+ }
13
+
14
+ export const allStarted = async (limit: number | null = null, options?: ProgressQueryOptions) =>
15
+ db.contentProgress.started(limit, options ?? defaultQueryOptions)
16
+
17
+ export const allCompleted = async (limit: number | null = null, options?: ProgressQueryOptions) =>
18
+ db.contentProgress.completed(limit, options ?? defaultQueryOptions)
19
+
20
+ export const allCompletedByIds = async (contentIds: number[]) =>
21
+ db.contentProgress.completedByContentIds(contentIds)
22
+
23
+ export const allStartedOrCompleted = async (
24
+ limit?: number,
25
+ options: StartedOrCompletedOptions = {}
26
+ ) =>
27
+ db.contentProgress
28
+ .startedOrCompleted({
29
+ ...options,
30
+ limit,
31
+ updatedAfter: Math.floor(Date.now() / 1000) - SIXTY_DAYS_IN_SECONDS,
32
+ })
33
+ .then((r) => r.data)
@@ -0,0 +1,6 @@
1
+ import * as state from './state'
2
+ import * as collections from './collections'
3
+
4
+ export const Progress = { ...state, ...collections }
5
+
6
+ export type { ProgressContentFilter, ProgressQueryOptions, StartedOrCompletedOptions } from './types'
@@ -0,0 +1,43 @@
1
+ import { db } from '../../sync'
2
+ import { CollectionParameter } from '../../sync/models/ContentProgress'
3
+
4
+ export const getByIds = async <V>(
5
+ contentIds: number[],
6
+ dataKey: string,
7
+ defaultValue: V,
8
+ collection?: CollectionParameter
9
+ ): Promise<Map<number, V>> => {
10
+ if (contentIds.length === 0) return new Map()
11
+
12
+ const progress = new Map(contentIds.map((id) => [id, defaultValue]))
13
+ await db.contentProgress.getSomeProgressByContentIds(contentIds, collection).then((r) => {
14
+ r.data.forEach((p) => {
15
+ progress.set(p.content_id, p[dataKey] ?? defaultValue)
16
+ })
17
+ })
18
+ return progress
19
+ }
20
+
21
+ export const getById = async <V>(
22
+ contentId: number,
23
+ dataKey: string,
24
+ defaultValue: V,
25
+ collection?: CollectionParameter
26
+ ): Promise<V> => {
27
+ if (!contentId) return defaultValue
28
+ return db.contentProgress
29
+ .getOneProgressByContentId(contentId, collection)
30
+ .then((r) => r.data?.[dataKey] ?? defaultValue)
31
+ }
32
+
33
+ export const getByRecordIds = async <V>(ids: string[], dataKey: string, defaultValue: V) => {
34
+ const progress = Object.fromEntries(ids.map((id) => [id, defaultValue]))
35
+
36
+ await db.contentProgress.getSomeProgressByRecordIds(ids).then((r) => {
37
+ r.data.forEach((p) => {
38
+ progress[p.id] = p[dataKey] ?? defaultValue
39
+ })
40
+ })
41
+
42
+ return progress
43
+ }
@@ -0,0 +1,50 @@
1
+ import { db } from '../sync'
2
+ import { COLLECTION_TYPE, CollectionParameter } from '../sync/models/ContentProgress'
3
+ import { getById, getByIds, getByRecordIds } from './internal/queries'
4
+
5
+ export const state = async (contentId: number, collection?: CollectionParameter) =>
6
+ getById(contentId, 'state', '', collection)
7
+
8
+ export const stateByIds = async (contentIds: number[], collection?: CollectionParameter) =>
9
+ getByIds(contentIds, 'state', '', collection)
10
+
11
+ export const stateByRecordIds = async (ids: string[]) => getByRecordIds(ids, 'state', '')
12
+
13
+ export const playbackPositionByIds = async (contentIds: number[], collection?: CollectionParameter) =>
14
+ getByIds(contentIds, 'resume_time_seconds', 0, collection)
15
+
16
+ export const playbackPositionByRecordIds = async (ids: string[]) =>
17
+ getByRecordIds(ids, 'resume_time_seconds', 0)
18
+
19
+ export const lastInteractedOf = (
20
+ contentIds: number[],
21
+ collection?: CollectionParameter
22
+ ): Promise<number | undefined> =>
23
+ db.contentProgress
24
+ .mostRecentlyUpdatedId(contentIds, collection)
25
+ .then((r) => (r.data ? parseInt(r.data, 10) : undefined))
26
+
27
+ export const incompleteLesson = (
28
+ progressOnItems: Map<number, string>,
29
+ contentType: string,
30
+ currentContentId?: number
31
+ ): number | null | undefined => {
32
+ const ids = Array.from(progressOnItems.keys())
33
+ const getProgress = (id: number) => progressOnItems.get(id)
34
+
35
+ if (contentType === 'guided-course' || contentType === COLLECTION_TYPE.LEARNING_PATH) {
36
+ return ids.find((id) => getProgress(id) !== 'completed') || ids.at(0)
37
+ }
38
+
39
+ const currentIndex = ids.indexOf(Number(currentContentId))
40
+ const startIndex = currentIndex === -1 ? 0 : currentIndex + 1
41
+
42
+ for (let i = startIndex; i < ids.length; i++) {
43
+ const id = ids[i]
44
+ if (getProgress(id) !== 'completed') {
45
+ return id
46
+ }
47
+ }
48
+
49
+ return ids[0]
50
+ }
@@ -0,0 +1,16 @@
1
+ export interface ProgressContentFilter {
2
+ aLaCarte?: boolean
3
+ learningPaths?: boolean
4
+ }
5
+
6
+ export interface ProgressQueryOptions {
7
+ onlyIds?: boolean
8
+ include?: ProgressContentFilter
9
+ }
10
+
11
+ export interface StartedOrCompletedOptions {
12
+ brand?: string
13
+ contentTypes?: string[]
14
+ parentId?: number
15
+ include?: ProgressContentFilter
16
+ }
@@ -1193,13 +1193,11 @@ export async function fetchLiveEvent(brand, forcedContentId = null) {
1193
1193
  default:
1194
1194
  break
1195
1195
  }
1196
- let startDateTemp = new Date()
1197
- let endDateTemp = new Date()
1198
1196
 
1199
- startDateTemp = new Date(
1200
- startDateTemp.setMinutes(startDateTemp.getMinutes() + LIVE_EXTRA_MINUTES)
1201
- )
1202
- endDateTemp = new Date(endDateTemp.setMinutes(endDateTemp.getMinutes() - LIVE_EXTRA_MINUTES))
1197
+ const now = new Date()
1198
+
1199
+ const startOfYesterday = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() - 1)).toISOString()
1200
+ const endOfTomorrow = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 2)).toISOString()
1203
1201
 
1204
1202
  const liveEventFields = getLiveFields().concat(
1205
1203
  `'event_coach_calendar_id': coalesce(calendar_id, '${defaultCalendarID}')`
@@ -1212,15 +1210,25 @@ export async function fetchLiveEvent(brand, forcedContentId = null) {
1212
1210
  : `status == 'scheduled'
1213
1211
  && (brand == '${brand}' || live_global_event == true)
1214
1212
  && defined(live_event_start_time)
1215
- && live_event_start_time <= '${getSanityDate(startDateTemp, false)}'
1216
- && live_event_end_time >= '${getSanityDate(endDateTemp, false)}'`
1213
+ && live_event_start_time >= '${startOfYesterday}'
1214
+ && live_event_start_time < '${endOfTomorrow}'`
1217
1215
 
1218
1216
  const filter = await new FilterBuilder(baseFilter, { bypassPermissions: true }).buildFilter()
1219
1217
 
1220
- // This query finds the first scheduled event (sorted by start_time) that ends after now()
1221
- const query = `*[${filter}]{${fieldsString}} | order(live_event_start_time)[0...1]`
1218
+ const events = await fetchSanity(
1219
+ `*[${filter}]{${fieldsString}} | order(live_event_start_time asc)`,
1220
+ true,
1221
+ { processNeedAccess: false }
1222
+ )
1222
1223
 
1223
- return await fetchSanity(query, false, { processNeedAccess: true })
1224
+ const clientNow = new Date()
1225
+ const windowStart = new Date(clientNow.getTime() - LIVE_EXTRA_MINUTES * 60000).toISOString()
1226
+ const windowEnd = new Date(clientNow.getTime() + LIVE_EXTRA_MINUTES * 60000).toISOString()
1227
+
1228
+ return events?.find(event =>
1229
+ event.live_event_end_time >= windowStart &&
1230
+ event.live_event_start_time <= windowEnd
1231
+ ) ?? null
1224
1232
  }
1225
1233
 
1226
1234
  /**
@@ -1667,8 +1675,8 @@ function contentResultsDecorator(results, fieldName, callback) {
1667
1675
  processChildren(contentItem)
1668
1676
  })
1669
1677
  } else {
1670
- result[fieldName] = callback(result)
1671
1678
  processChildren(result)
1679
+ result[fieldName] = callback(result)
1672
1680
  }
1673
1681
  })
1674
1682
  } else if (results.entity && Array.isArray(results.entity)) {
@@ -1705,8 +1713,8 @@ function contentResultsDecorator(results, fieldName, callback) {
1705
1713
  }
1706
1714
  })
1707
1715
  } else {
1716
+ processChildren(results)
1708
1717
  results[fieldName] = callback(results)
1709
- processChildren(results) // this on was always true
1710
1718
  }
1711
1719
 
1712
1720
  return results
@@ -2224,7 +2232,14 @@ export async function fetchScheduledAndNewReleases(
2224
2232
  return []
2225
2233
  }
2226
2234
 
2227
- return reorderScheduledAndNewReleases(r, limit)
2235
+ const reordered = reorderScheduledAndNewReleases(r, limit)
2236
+ const computedNow = new Date()
2237
+ return reordered.map(item => ({
2238
+ ...item,
2239
+ isLive: item.live_event_start_time && item.live_event_end_time
2240
+ ? new Date(item.live_event_start_time) <= computedNow && new Date(item.live_event_end_time) >= computedNow
2241
+ : false,
2242
+ }))
2228
2243
  }
2229
2244
 
2230
2245
  function reorderScheduledAndNewReleases(r, limit) {
@@ -16,25 +16,10 @@ jest.mock('../../../../../src/services/sync/repository-proxy', () => {
16
16
  return Promise.resolve({ data: mockLastInteracted })
17
17
  }),
18
18
  },
19
- practices: {
20
- queryAll: jest.fn().mockResolvedValue({ data: [] }),
21
- getAll: jest.fn().mockResolvedValue({ data: [] }),
22
- },
23
19
  }
24
20
  return { default: mockFns, ...mockFns }
25
21
  })
26
22
 
27
- jest.mock('../../../../../src/services/content-org/learning-paths', () => ({
28
- getDailySession: jest.fn().mockResolvedValue(null),
29
- onLearningPathCompletedActions: jest.fn().mockResolvedValue(undefined),
30
- }))
31
-
32
- jest.mock('../../../../../src/services/sanity.js', () => ({
33
- getHierarchy: jest.fn().mockResolvedValue({ metadata: {}, parents: {}, children: {} }),
34
- getHierarchies: jest.fn().mockResolvedValue({ metadata: {}, parents: {}, children: {} }),
35
- getSanityDate: jest.fn((date: Date) => date.toISOString()),
36
- }))
37
-
38
23
  import { initializeTestService } from '../../../../initializeTests.js'
39
24
  import {
40
25
  NAVIGATE_TO_FIELD,
@@ -75,11 +60,32 @@ beforeEach(() => {
75
60
  })
76
61
 
77
62
  describe('navigate-to decorator', () => {
78
- describe('navigateToDecorator (const)', () => {
63
+ describe('navigateToDecorator (factory)', () => {
79
64
  test('field is navigateTo', () => {
80
- expect(navigateToDecorator.field).toBe('navigateTo')
65
+ expect(navigateToDecorator().field).toBe('navigateTo')
81
66
  expect(NAVIGATE_TO_FIELD).toBe('navigateTo')
82
67
  })
68
+
69
+ test('recurse is false', () => {
70
+ expect(navigateToDecorator().recurse).toBe(false)
71
+ })
72
+
73
+ test('without ctx — navigates single item via DB', async () => {
74
+ const item = parent(1, 'course', [child(101), child(102)])
75
+ const result = await navigateToDecorator().compute(item)
76
+ expect(result).toMatchObject({ id: 101, child: null })
77
+ })
78
+
79
+ test('with ctx — uses ctx states, not DB', async () => {
80
+ // DB says course is started; ctx overrides to not-started → should go to first child
81
+ mockProgressRecords = [
82
+ { content_id: 1, state: 'started', progress_percent: 50, updated_at: 1000 },
83
+ ]
84
+ const ctx = { states: new Map<number, string>([[1, '']]) }
85
+ const item = parent(1, 'course', [child(101), child(102)])
86
+ const result = await navigateToDecorator(ctx).compute(item)
87
+ expect(result).toMatchObject({ id: 101 })
88
+ })
83
89
  })
84
90
 
85
91
  describe('decorateNavigateTo', () => {
@@ -179,6 +185,21 @@ describe('navigate-to decorator', () => {
179
185
  })
180
186
  })
181
187
 
188
+ test('two-depth: started course-collection, last-interacted course has completed lessons → first incomplete lesson, not lesson[0]', async () => {
189
+ mockProgressRecords = [
190
+ { content_id: 1, state: 'started', progress_percent: 50, updated_at: 1000 },
191
+ { content_id: 101, state: 'started', progress_percent: 50, updated_at: 900 },
192
+ { content_id: 201, state: 'completed', progress_percent: 100, updated_at: 800 },
193
+ { content_id: 202, state: '', progress_percent: 0, updated_at: 0 },
194
+ { content_id: 203, state: '', progress_percent: 0, updated_at: 0 },
195
+ ]
196
+ mockLastInteracted = 101
197
+ const course = parent(101, 'course', [child(201), child(202), child(203)])
198
+ const collection = parent(1, 'course-collection', [course])
199
+ const result = await decorateNavigateTo(collection)
200
+ expect(result.navigateTo).toMatchObject({ id: 101, child: { id: 202 } })
201
+ })
202
+
182
203
  test('decorates every item in an array', async () => {
183
204
  const items = [parent(1, 'course', [child(101)]), parent(2, 'lesson', [child(201)])]
184
205
  const result = await decorateNavigateTo(items)
@@ -192,18 +213,6 @@ describe('navigate-to decorator', () => {
192
213
  expect(result).toBe(items)
193
214
  })
194
215
 
195
- test('navigateToDecorator.compute fires once per top-level item, never on descendants', async () => {
196
- const spy = jest.spyOn(navigateToDecorator, 'compute')
197
- const courseChild = parent(101, 'course', [child(201), child(202)])
198
- const collection = parent(1, 'course-collection', [courseChild, parent(102, 'course', [])])
199
- const standalone = parent(2, 'course', [child(301)])
200
- await decorateNavigateTo([collection, standalone])
201
- expect(spy).toHaveBeenCalledTimes(2)
202
- expect(spy).toHaveBeenCalledWith(collection)
203
- expect(spy).toHaveBeenCalledWith(standalone)
204
- spy.mockRestore()
205
- })
206
-
207
216
  test('descendants of decorated items do not receive navigateTo field', async () => {
208
217
  const item = parent(1, 'course', [child(101), child(102)])
209
218
  const result = await decorateNavigateTo(item)
@@ -237,6 +246,92 @@ describe('navigate-to decorator', () => {
237
246
  expect(result.navigateTo).toMatchObject({ id: 102 })
238
247
  })
239
248
 
249
+ test('guided-course STARTED, lastInteracted not in children → first incomplete', async () => {
250
+ mockProgressRecords = [
251
+ { content_id: 1, state: 'started', progress_percent: 40, updated_at: 1000 },
252
+ { content_id: 101, state: 'completed', progress_percent: 100, updated_at: 900 },
253
+ { content_id: 102, state: '', progress_percent: 0, updated_at: 0 },
254
+ { content_id: 103, state: '', progress_percent: 0, updated_at: 0 },
255
+ ]
256
+ mockLastInteracted = 999
257
+ const item = parent(1, 'guided-course', [child(101), child(102), child(103)])
258
+ const result = await decorateNavigateTo(item)
259
+ expect(result.navigateTo).toMatchObject({ id: 102 })
260
+ })
261
+
262
+ test('guided-course STARTED, lastInteracted not in children, all complete → first child', async () => {
263
+ mockProgressRecords = [
264
+ { content_id: 1, state: 'started', progress_percent: 100, updated_at: 1000 },
265
+ { content_id: 101, state: 'completed', progress_percent: 100, updated_at: 900 },
266
+ { content_id: 102, state: 'completed', progress_percent: 100, updated_at: 1000 },
267
+ ]
268
+ mockLastInteracted = 999
269
+ const item = parent(1, 'guided-course', [child(101), child(102)])
270
+ const result = await decorateNavigateTo(item)
271
+ expect(result.navigateTo).toMatchObject({ id: 101 })
272
+ })
273
+
274
+ test('learning-path-v2 STARTED, lastInteracted not in children → first incomplete', async () => {
275
+ mockProgressRecords = [
276
+ { content_id: 1, state: 'started', progress_percent: 40, updated_at: 1000 },
277
+ { content_id: 101, state: 'completed', progress_percent: 100, updated_at: 900 },
278
+ { content_id: 102, state: '', progress_percent: 0, updated_at: 0 },
279
+ ]
280
+ mockLastInteracted = 999
281
+ const item = parent(1, COLLECTION_TYPE.LEARNING_PATH, [child(101), child(102)])
282
+ const result = await decorateNavigateTo(item)
283
+ expect(result.navigateTo).toMatchObject({ id: 102 })
284
+ })
285
+
286
+ test('course STARTED, lastInteracted not in children → first incomplete', async () => {
287
+ mockProgressRecords = [
288
+ { content_id: 1, state: 'started', progress_percent: 40, updated_at: 1000 },
289
+ { content_id: 101, state: 'completed', progress_percent: 100, updated_at: 900 },
290
+ { content_id: 102, state: '', progress_percent: 0, updated_at: 0 },
291
+ { content_id: 103, state: '', progress_percent: 0, updated_at: 0 },
292
+ ]
293
+ mockLastInteracted = 999
294
+ const item = parent(1, 'course', [child(101), child(102), child(103)])
295
+ const result = await decorateNavigateTo(item)
296
+ expect(result.navigateTo).toMatchObject({ id: 102 })
297
+ })
298
+
299
+ test('skill-pack STARTED, lastInteracted not in children → first incomplete', async () => {
300
+ mockProgressRecords = [
301
+ { content_id: 1, state: 'started', progress_percent: 40, updated_at: 1000 },
302
+ { content_id: 101, state: 'completed', progress_percent: 100, updated_at: 900 },
303
+ { content_id: 102, state: '', progress_percent: 0, updated_at: 0 },
304
+ ]
305
+ mockLastInteracted = 999
306
+ const item = parent(1, 'skill-pack', [child(101), child(102)])
307
+ const result = await decorateNavigateTo(item)
308
+ expect(result.navigateTo).toMatchObject({ id: 102 })
309
+ })
310
+
311
+ test('song-tutorial STARTED, lastInteracted not in children → first incomplete', async () => {
312
+ mockProgressRecords = [
313
+ { content_id: 1, state: 'started', progress_percent: 40, updated_at: 1000 },
314
+ { content_id: 101, state: 'completed', progress_percent: 100, updated_at: 900 },
315
+ { content_id: 102, state: '', progress_percent: 0, updated_at: 0 },
316
+ ]
317
+ mockLastInteracted = 999
318
+ const item = parent(1, 'song-tutorial', [child(101), child(102)])
319
+ const result = await decorateNavigateTo(item)
320
+ expect(result.navigateTo).toMatchObject({ id: 102 })
321
+ })
322
+
323
+ test('course STARTED, lastInteracted not in children, all complete → first child', async () => {
324
+ mockProgressRecords = [
325
+ { content_id: 1, state: 'started', progress_percent: 100, updated_at: 1000 },
326
+ { content_id: 101, state: 'completed', progress_percent: 100, updated_at: 900 },
327
+ { content_id: 102, state: 'completed', progress_percent: 100, updated_at: 1000 },
328
+ ]
329
+ mockLastInteracted = 999
330
+ const item = parent(1, 'course', [child(101), child(102)])
331
+ const result = await decorateNavigateTo(item)
332
+ expect(result.navigateTo).toMatchObject({ id: 101 })
333
+ })
334
+
240
335
  test('two-depth started but lastInteracted child not in collection → null', async () => {
241
336
  mockProgressRecords = [
242
337
  { content_id: 1, state: 'started', progress_percent: 50, updated_at: 1000 },
@@ -0,0 +1,300 @@
1
+ let mockProgressRecords: any[] = []
2
+ let mockRecordsById: Record<string, any> = {}
3
+ let mockLastInteracted: string | null = null
4
+ let mockStarted: any = { data: [] }
5
+ let mockCompleted: any = { data: [] }
6
+ let mockCompletedByContentIds: any = { data: [] }
7
+ let mockStartedOrCompleted: any = { data: [] }
8
+
9
+ const repoMocks = {
10
+ contentProgress: {
11
+ getOneProgressByContentId: jest.fn().mockImplementation((contentId: number) => {
12
+ const record = mockProgressRecords.find((r) => r.content_id === contentId)
13
+ return Promise.resolve({ data: record || null })
14
+ }),
15
+ getSomeProgressByContentIds: jest.fn().mockImplementation((contentIds: number[]) => {
16
+ const records = mockProgressRecords.filter((r) => contentIds.includes(r.content_id))
17
+ return Promise.resolve({ data: records })
18
+ }),
19
+ getSomeProgressByRecordIds: jest.fn().mockImplementation((ids: string[]) => {
20
+ const records = ids.map((id) => mockRecordsById[id]).filter(Boolean)
21
+ return Promise.resolve({ data: records })
22
+ }),
23
+ mostRecentlyUpdatedId: jest.fn().mockImplementation(() => {
24
+ return Promise.resolve({ data: mockLastInteracted })
25
+ }),
26
+ started: jest.fn().mockImplementation(() => Promise.resolve(mockStarted)),
27
+ completed: jest.fn().mockImplementation(() => Promise.resolve(mockCompleted)),
28
+ completedByContentIds: jest
29
+ .fn()
30
+ .mockImplementation(() => Promise.resolve(mockCompletedByContentIds)),
31
+ startedOrCompleted: jest.fn().mockImplementation(() => Promise.resolve(mockStartedOrCompleted)),
32
+ },
33
+ }
34
+
35
+ jest.mock('../../../src/services/sync/repository-proxy', () => ({
36
+ __esModule: true,
37
+ default: repoMocks,
38
+ ...repoMocks,
39
+ }))
40
+
41
+ import { Progress } from '../../../src/services/progress'
42
+ import { COLLECTION_TYPE } from '../../../src/services/sync/models/ContentProgress'
43
+
44
+ beforeEach(() => {
45
+ jest.clearAllMocks()
46
+ mockProgressRecords = []
47
+ mockRecordsById = {}
48
+ mockLastInteracted = null
49
+ mockStarted = { data: [] }
50
+ mockCompleted = { data: [] }
51
+ mockCompletedByContentIds = { data: [] }
52
+ mockStartedOrCompleted = { data: [] }
53
+ })
54
+
55
+ describe('Progress.state', () => {
56
+ test('returns state from record', async () => {
57
+ mockProgressRecords = [{ content_id: 100, state: 'started' }]
58
+ expect(await Progress.state(100)).toBe('started')
59
+ })
60
+
61
+ test('returns empty string when record missing', async () => {
62
+ expect(await Progress.state(999)).toBe('')
63
+ })
64
+
65
+ test('returns empty string when contentId is 0', async () => {
66
+ expect(await Progress.state(0)).toBe('')
67
+ expect(repoMocks.contentProgress.getOneProgressByContentId).not.toHaveBeenCalled()
68
+ })
69
+
70
+ test('forwards collection argument', async () => {
71
+ const collection = { id: 5, type: COLLECTION_TYPE.LEARNING_PATH }
72
+ await Progress.state(100, collection)
73
+ expect(repoMocks.contentProgress.getOneProgressByContentId).toHaveBeenCalledWith(
74
+ 100,
75
+ collection
76
+ )
77
+ })
78
+ })
79
+
80
+ describe('Progress.stateByIds', () => {
81
+ test('returns Map with states for found ids and defaults for missing', async () => {
82
+ mockProgressRecords = [
83
+ { content_id: 100, state: 'started' },
84
+ { content_id: 300, state: 'completed' },
85
+ ]
86
+ const result = await Progress.stateByIds([100, 300, 999])
87
+ expect(result.get(100)).toBe('started')
88
+ expect(result.get(300)).toBe('completed')
89
+ expect(result.get(999)).toBe('')
90
+ })
91
+
92
+ test('returns empty Map for empty input without hitting db', async () => {
93
+ const result = await Progress.stateByIds([])
94
+ expect(result.size).toBe(0)
95
+ expect(repoMocks.contentProgress.getSomeProgressByContentIds).not.toHaveBeenCalled()
96
+ })
97
+
98
+ test('forwards collection argument', async () => {
99
+ const collection = { id: 7, type: COLLECTION_TYPE.PLAYLIST }
100
+ await Progress.stateByIds([1, 2], collection)
101
+ expect(repoMocks.contentProgress.getSomeProgressByContentIds).toHaveBeenCalledWith(
102
+ [1, 2],
103
+ collection
104
+ )
105
+ })
106
+ })
107
+
108
+ describe('Progress.stateByRecordIds', () => {
109
+ test('returns object with states keyed by record id', async () => {
110
+ mockRecordsById = {
111
+ '100:self:0': { id: '100:self:0', state: 'started' },
112
+ '300:self:0': { id: '300:self:0', state: 'completed' },
113
+ }
114
+ const result = await Progress.stateByRecordIds(['100:self:0', '300:self:0', '999:self:0'])
115
+ expect(result['100:self:0']).toBe('started')
116
+ expect(result['300:self:0']).toBe('completed')
117
+ expect(result['999:self:0']).toBe('')
118
+ })
119
+ })
120
+
121
+ describe('Progress.playbackPositionByIds', () => {
122
+ test('returns Map with resume_time_seconds for found ids and 0 default', async () => {
123
+ mockProgressRecords = [
124
+ { content_id: 1, resume_time_seconds: 42 },
125
+ { content_id: 2, resume_time_seconds: 0 },
126
+ ]
127
+ const result = await Progress.playbackPositionByIds([1, 2, 3])
128
+ expect(result.get(1)).toBe(42)
129
+ expect(result.get(2)).toBe(0)
130
+ expect(result.get(3)).toBe(0)
131
+ })
132
+
133
+ test('returns default 0 when field is null', async () => {
134
+ mockProgressRecords = [{ content_id: 1, resume_time_seconds: null }]
135
+ const result = await Progress.playbackPositionByIds([1])
136
+ expect(result.get(1)).toBe(0)
137
+ })
138
+ })
139
+
140
+ describe('Progress.playbackPositionByRecordIds', () => {
141
+ test('returns object with resume_time_seconds keyed by record id', async () => {
142
+ mockRecordsById = {
143
+ '100:self:0': { id: '100:self:0', resume_time_seconds: 120 },
144
+ '300:self:0': { id: '300:self:0', resume_time_seconds: 0 },
145
+ }
146
+ const result = await Progress.playbackPositionByRecordIds(['100:self:0', '300:self:0', '999:self:0'])
147
+ expect(result['100:self:0']).toBe(120)
148
+ expect(result['300:self:0']).toBe(0)
149
+ expect(result['999:self:0']).toBe(0)
150
+ })
151
+
152
+ test('returns default 0 when field is null', async () => {
153
+ mockRecordsById = { '100:self:0': { id: '100:self:0', resume_time_seconds: null } }
154
+ const result = await Progress.playbackPositionByRecordIds(['100:self:0'])
155
+ expect(result['100:self:0']).toBe(0)
156
+ })
157
+ })
158
+
159
+ describe('Progress.lastInteractedOf', () => {
160
+ test('parses numeric string to integer', async () => {
161
+ mockLastInteracted = '101'
162
+ expect(await Progress.lastInteractedOf([100, 101])).toBe(101)
163
+ })
164
+
165
+ test('returns undefined when repository returns null', async () => {
166
+ mockLastInteracted = null
167
+ expect(await Progress.lastInteractedOf([100, 101])).toBeUndefined()
168
+ })
169
+
170
+ test('forwards args to repository', async () => {
171
+ const collection = { id: 9, type: COLLECTION_TYPE.LEARNING_PATH }
172
+ await Progress.lastInteractedOf([1, 2, 3], collection)
173
+ expect(repoMocks.contentProgress.mostRecentlyUpdatedId).toHaveBeenCalledWith(
174
+ [1, 2, 3],
175
+ collection
176
+ )
177
+ })
178
+ })
179
+
180
+ describe('Progress.incompleteLesson', () => {
181
+ test('guided-course returns first non-completed id', () => {
182
+ const states = new Map<number, string>([
183
+ [1, 'completed'],
184
+ [2, 'started'],
185
+ [3, ''],
186
+ ])
187
+ expect(Progress.incompleteLesson(states, 'guided-course', 999)).toBe(2)
188
+ })
189
+
190
+ test('learning-path returns first non-completed id', () => {
191
+ const states = new Map<number, string>([
192
+ [1, 'completed'],
193
+ [2, 'completed'],
194
+ [3, 'started'],
195
+ ])
196
+ expect(Progress.incompleteLesson(states, COLLECTION_TYPE.LEARNING_PATH, 999)).toBe(3)
197
+ })
198
+
199
+ test('guided-course falls back to first id when all completed', () => {
200
+ const states = new Map<number, string>([
201
+ [10, 'completed'],
202
+ [20, 'completed'],
203
+ ])
204
+ expect(Progress.incompleteLesson(states, 'guided-course', 999)).toBe(10)
205
+ })
206
+
207
+ test('other type: returns next non-completed after currentContentId', () => {
208
+ const states = new Map<number, string>([
209
+ [1, 'completed'],
210
+ [2, 'started'],
211
+ [3, 'completed'],
212
+ [4, 'started'],
213
+ ])
214
+ expect(Progress.incompleteLesson(states, 'course', 2)).toBe(4)
215
+ })
216
+
217
+ test('other type: wraps to first id when no incomplete after current', () => {
218
+ const states = new Map<number, string>([
219
+ [1, 'started'],
220
+ [2, 'started'],
221
+ [3, 'completed'],
222
+ ])
223
+ expect(Progress.incompleteLesson(states, 'course', 3)).toBe(1)
224
+ })
225
+
226
+ test('other type: scans from start when currentContentId not in map → first incomplete', () => {
227
+ const states = new Map<number, string>([
228
+ [1, 'completed'],
229
+ [2, 'started'],
230
+ ])
231
+ expect(Progress.incompleteLesson(states, 'course', 999)).toBe(2)
232
+ })
233
+
234
+ test('other type: scans from start when currentContentId not in map and all complete → first id', () => {
235
+ const states = new Map<number, string>([
236
+ [1, 'completed'],
237
+ [2, 'completed'],
238
+ ])
239
+ expect(Progress.incompleteLesson(states, 'course', 999)).toBe(1)
240
+ })
241
+ })
242
+
243
+ describe('Progress.allStarted', () => {
244
+ test('delegates to repo and applies default options', async () => {
245
+ mockStarted = { data: [1, 2, 3] }
246
+ const result = await Progress.allStarted()
247
+ expect(result).toEqual({ data: [1, 2, 3] })
248
+ expect(repoMocks.contentProgress.started).toHaveBeenCalledWith(null, {
249
+ onlyIds: true,
250
+ include: { aLaCarte: true, learningPaths: false },
251
+ })
252
+ })
253
+
254
+ test('forwards custom limit and options', async () => {
255
+ const opts = { onlyIds: false, include: { aLaCarte: false, learningPaths: true } }
256
+ await Progress.allStarted(10, opts)
257
+ expect(repoMocks.contentProgress.started).toHaveBeenCalledWith(10, opts)
258
+ })
259
+ })
260
+
261
+ describe('Progress.allCompleted', () => {
262
+ test('delegates to repo with default options', async () => {
263
+ mockCompleted = { data: [9] }
264
+ const result = await Progress.allCompleted()
265
+ expect(result).toEqual({ data: [9] })
266
+ expect(repoMocks.contentProgress.completed).toHaveBeenCalledWith(null, {
267
+ onlyIds: true,
268
+ include: { aLaCarte: true, learningPaths: false },
269
+ })
270
+ })
271
+ })
272
+
273
+ describe('Progress.allCompletedByIds', () => {
274
+ test('delegates to repo with passed contentIds', async () => {
275
+ mockCompletedByContentIds = { data: [{ content_id: 5 }] }
276
+ const result = await Progress.allCompletedByIds([5, 6])
277
+ expect(result).toEqual({ data: [{ content_id: 5 }] })
278
+ expect(repoMocks.contentProgress.completedByContentIds).toHaveBeenCalledWith([5, 6])
279
+ })
280
+ })
281
+
282
+ describe('Progress.allStartedOrCompleted', () => {
283
+ test('unwraps r.data from repo response', async () => {
284
+ mockStartedOrCompleted = { data: [{ content_id: 1 }, { content_id: 2 }] }
285
+ const result = await Progress.allStartedOrCompleted()
286
+ expect(result).toEqual([{ content_id: 1 }, { content_id: 2 }])
287
+ })
288
+
289
+ test('passes limit and updatedAfter window (60 days)', async () => {
290
+ const before = Math.floor(Date.now() / 1000) - 60 * 24 * 60 * 60
291
+ await Progress.allStartedOrCompleted(25, { brand: 'drumeo' })
292
+ const after = Math.floor(Date.now() / 1000) - 60 * 24 * 60 * 60
293
+
294
+ const args = repoMocks.contentProgress.startedOrCompleted.mock.calls[0][0]
295
+ expect(args.limit).toBe(25)
296
+ expect(args.brand).toBe('drumeo')
297
+ expect(args.updatedAfter).toBeGreaterThanOrEqual(before)
298
+ expect(args.updatedAfter).toBeLessThanOrEqual(after)
299
+ })
300
+ })
@@ -0,0 +1,237 @@
1
+ import {
2
+ decorateAll,
3
+ decorateAllAsync,
4
+ decorateAsync,
5
+ type FieldDecorator,
6
+ type FieldDecoratorAsync,
7
+ } from '../../../src/lib/sanity/decorators/base'
8
+ import {
9
+ accessDecorator,
10
+ decorateAccess,
11
+ type AccessDecoratable,
12
+ } from '../../../src/lib/sanity/decorators/need-access'
13
+ import {
14
+ pageTypeDecorator,
15
+ decoratePageType,
16
+ type PageTypeDecoratable,
17
+ } from '../../../src/lib/sanity/decorators/page-type'
18
+ import {
19
+ decorateNavigateTo,
20
+ navigateToDecorator,
21
+ WithNavigateTo,
22
+ type NavigateToDecoratable,
23
+ } from '../../../src/lib/sanity/decorators/navigate-to'
24
+ import type { UserPermissions } from '../../../src/services/permissions'
25
+
26
+ interface ContentRow extends AccessDecoratable, PageTypeDecoratable {
27
+ id: number
28
+ type?: string
29
+ permission_id?: number[]
30
+ children?: ContentRow[]
31
+ }
32
+
33
+ const perms: UserPermissions = {
34
+ permissions: [78, 91],
35
+ isAdmin: false,
36
+ isModerator: false,
37
+ isABasicMember: true,
38
+ }
39
+
40
+ const rows: ContentRow[] = [
41
+ {
42
+ id: 1,
43
+ type: 'course',
44
+ permission_id: [78],
45
+ children: [
46
+ { id: 2, type: 'song', permission_id: [91] },
47
+ { id: 3, type: 'play-along' },
48
+ ],
49
+ },
50
+ ]
51
+
52
+ export function singleDecoratorViaWrapper() {
53
+ const decorated = decorateAccess(rows, perms)
54
+ decorated[0].need_access satisfies boolean
55
+ return decorated
56
+ }
57
+
58
+ export function chainedWrappers() {
59
+ const withAccess = decorateAccess(rows, perms)
60
+ const withBoth = decoratePageType(withAccess)
61
+ withBoth[0].need_access satisfies boolean
62
+ withBoth[0].page_type satisfies 'song' | 'lesson'
63
+ return withBoth
64
+ }
65
+
66
+ export function composedSingleWalk() {
67
+ type Composed = AccessDecoratable & PageTypeDecoratable
68
+ const decorators: FieldDecorator<Composed>[] = [
69
+ accessDecorator(perms) as FieldDecorator<Composed>,
70
+ pageTypeDecorator as FieldDecorator<Composed>,
71
+ ]
72
+ return decorateAll(rows, decorators)
73
+ }
74
+
75
+ export function conditionalComposition(opts: { withAccess: boolean; withPageType: boolean }) {
76
+ type Composed = AccessDecoratable & PageTypeDecoratable
77
+ const decorators: FieldDecorator<Composed>[] = []
78
+ if (opts.withAccess) {
79
+ decorators.push(accessDecorator(perms) as FieldDecorator<Composed>)
80
+ }
81
+ if (opts.withPageType) {
82
+ decorators.push(pageTypeDecorator as FieldDecorator<Composed>)
83
+ }
84
+ return decorateAll(rows, decorators)
85
+ }
86
+
87
+ interface ProgressDecoratable extends AccessDecoratable {
88
+ progress_percent?: number
89
+ is_liked?: boolean
90
+ }
91
+
92
+ async function fetchProgress(id: number): Promise<number> {
93
+ return id * 10
94
+ }
95
+
96
+ async function fetchLiked(id: number): Promise<boolean> {
97
+ return id % 2 === 0
98
+ }
99
+
100
+ export async function singleAsyncDecorator() {
101
+ const decorated = (await decorateAsync(rows, 'progress_percent', (item) =>
102
+ fetchProgress(item.id as number)
103
+ )) as ProgressDecoratable[]
104
+ decorated[0].progress_percent satisfies number | undefined
105
+ return decorated
106
+ }
107
+
108
+ export async function parallelAsyncDecorators() {
109
+ const decorators: FieldDecoratorAsync<ProgressDecoratable>[] = [
110
+ {
111
+ field: 'progress_percent',
112
+ compute: (item) => fetchProgress(item.id as number),
113
+ },
114
+ {
115
+ field: 'is_liked',
116
+ compute: (item) => fetchLiked(item.id as number),
117
+ },
118
+ ]
119
+ const decorated = (await decorateAllAsync(
120
+ rows as ProgressDecoratable[],
121
+ decorators
122
+ )) as ProgressDecoratable[]
123
+ decorated[0].progress_percent satisfies number | undefined
124
+ decorated[0].is_liked satisfies boolean | undefined
125
+ return decorated
126
+ }
127
+
128
+ export async function mixedSyncThenAsync() {
129
+ const withAccess = decorateAll(rows, [
130
+ accessDecorator(perms) as FieldDecorator<ProgressDecoratable>,
131
+ ]) as ProgressDecoratable[]
132
+ return decorateAllAsync(withAccess, [
133
+ {
134
+ field: 'progress_percent',
135
+ compute: (item) => fetchProgress(item.id as number),
136
+ },
137
+ {
138
+ field: 'is_liked',
139
+ compute: (item) => fetchLiked(item.id as number),
140
+ },
141
+ ])
142
+ }
143
+
144
+ const courseLesson = (id: number): NavigateToDecoratable => ({
145
+ id,
146
+ type: 'course-lesson',
147
+ brand: 'drumeo',
148
+ thumbnail: '',
149
+ published_on: null,
150
+ status: 'published',
151
+ })
152
+
153
+ const navigateRows: NavigateToDecoratable[] = [
154
+ {
155
+ id: 1,
156
+ type: 'course',
157
+ brand: 'drumeo',
158
+ thumbnail: '',
159
+ published_on: null,
160
+ status: 'published',
161
+ children: [courseLesson(101), courseLesson(102), courseLesson(103)],
162
+ },
163
+ {
164
+ id: 2,
165
+ type: 'course-collection',
166
+ brand: 'drumeo',
167
+ thumbnail: '',
168
+ published_on: null,
169
+ status: 'published',
170
+ children: [
171
+ {
172
+ id: 201,
173
+ type: 'course',
174
+ brand: 'drumeo',
175
+ thumbnail: '',
176
+ published_on: null,
177
+ status: 'published',
178
+ children: [courseLesson(301), courseLesson(302)],
179
+ },
180
+ ],
181
+ },
182
+ ]
183
+
184
+ export async function singleAsyncNavigateTo() {
185
+ const decorated = await decorateNavigateTo(navigateRows)
186
+ void decorated[0].navigateTo
187
+ void decorated[1].navigateTo?.child
188
+ return decorated
189
+ }
190
+
191
+ export async function navigateToOnSingleItem() {
192
+ const decorated = await decorateNavigateTo(navigateRows[0])
193
+ void decorated.navigateTo
194
+ return decorated
195
+ }
196
+
197
+ export async function navigateToComposedWithAccess() {
198
+ interface ContentWithNav extends NavigateToDecoratable, AccessDecoratable {
199
+ permission_id?: number[]
200
+ children?: ContentWithNav[]
201
+ }
202
+
203
+ const items: ContentWithNav[] = navigateRows.map((row) => ({
204
+ ...row,
205
+ permission_id: [78],
206
+ }))
207
+
208
+ const withAccess = decorateAccess(items, perms)
209
+ const withBoth = await decorateNavigateTo(withAccess)
210
+
211
+ withBoth[0].need_access satisfies boolean
212
+ void withBoth[0].navigateTo
213
+ return withBoth
214
+ }
215
+
216
+ export async function navigateToParallelWithProgress() {
217
+ interface ContentWithNavAndProgress extends NavigateToDecoratable {
218
+ progress_percent?: number
219
+ }
220
+
221
+ const items = navigateRows as ContentWithNavAndProgress[]
222
+ const decorators: FieldDecoratorAsync<ContentWithNavAndProgress>[] = [
223
+ navigateToDecorator as FieldDecoratorAsync<ContentWithNavAndProgress>,
224
+ {
225
+ field: 'progress_percent',
226
+ compute: (item) => fetchProgress(item.id),
227
+ },
228
+ ]
229
+ const decorated = (await decorateAllAsync(
230
+ items,
231
+ decorators
232
+ )) as WithNavigateTo<ContentWithNavAndProgress>[]
233
+
234
+ void decorated[0].navigateTo
235
+ decorated[0].progress_percent satisfies number | undefined
236
+ return decorated
237
+ }