musora-content-services 2.162.0 → 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
package/CHANGELOG.md CHANGED
@@ -2,6 +2,21 @@
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
+
18
+ ### [2.162.1](https://github.com/railroadmedia/musora-content-services/compare/v2.162.0...v2.162.1) (2026-06-04)
19
+
5
20
  ## [2.162.0](https://github.com/railroadmedia/musora-content-services/compare/v2.161.4...v2.162.0) (2026-06-03)
6
21
 
7
22
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "musora-content-services",
3
- "version": "2.162.0",
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
  }
package/src/index.d.ts CHANGED
@@ -177,7 +177,8 @@ import {
177
177
  likePost,
178
178
  search,
179
179
  unlikePost,
180
- updatePost
180
+ updatePost,
181
+ whoLikedPost
181
182
  } from './services/forums/posts.ts';
182
183
 
183
184
  import {
@@ -494,6 +495,11 @@ import {
494
495
  updateUserPractice
495
496
  } from './services/userActivity.js';
496
497
 
498
+ import {
499
+ whoLikedComment,
500
+ whoLikedContent
501
+ } from './services/whoLiked.ts';
502
+
497
503
  import {
498
504
  default as EventsAPI
499
505
  } from './services/eventsAPI';
@@ -847,6 +853,9 @@ declare module 'musora-content-services' {
847
853
  userOnboardingForBrand,
848
854
  verifyImageSRC,
849
855
  verifyLocalDataContext,
856
+ whoLikedComment,
857
+ whoLikedContent,
858
+ whoLikedPost,
850
859
  }
851
860
  }
852
861
 
package/src/index.js CHANGED
@@ -181,7 +181,8 @@ import {
181
181
  likePost,
182
182
  search,
183
183
  unlikePost,
184
- updatePost
184
+ updatePost,
185
+ whoLikedPost
185
186
  } from './services/forums/posts.ts';
186
187
 
187
188
  import {
@@ -498,6 +499,11 @@ import {
498
499
  updateUserPractice
499
500
  } from './services/userActivity.js';
500
501
 
502
+ import {
503
+ whoLikedComment,
504
+ whoLikedContent
505
+ } from './services/whoLiked.ts';
506
+
501
507
  export {
502
508
  PermissionsAdapter,
503
509
  PermissionsV1Adapter,
@@ -846,6 +852,9 @@ export {
846
852
  userOnboardingForBrand,
847
853
  verifyImageSRC,
848
854
  verifyLocalDataContext,
855
+ whoLikedComment,
856
+ whoLikedContent,
857
+ whoLikedPost,
849
858
  };
850
859
 
851
860
  export default EventsAPI
@@ -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
 
@@ -3,7 +3,7 @@
3
3
  */
4
4
  import { HttpClient } from '../../infrastructure/http/HttpClient'
5
5
  import { globalConfig } from '../config.js'
6
- import { ForumPost } from './types'
6
+ import { ForumPost, WhoLikedParams, WhoLikedResponse } from './types'
7
7
  import { PaginatedResponse } from '../api/types'
8
8
  import { markThreadAsRead } from './threads'
9
9
 
@@ -128,6 +128,33 @@ export async function likePost(postId: number, brand: string): Promise<void> {
128
128
  })
129
129
  }
130
130
 
131
+ /**
132
+ * Fetch the list of users who liked a forum post.
133
+ *
134
+ * @param {number} postId - The ID of the post.
135
+ * @param {string} brand - The brand context (e.g., "drumeo", "singeo").
136
+ * @param {WhoLikedParams} [params] - Optional pagination parameters.
137
+ * @returns {Promise<WhoLikedResponse>} - A promise that resolves to the paginated list of likers.
138
+ * @throws {HttpError} - If the request fails.
139
+ */
140
+ export async function whoLikedPost(
141
+ postId: number,
142
+ brand: string,
143
+ params: WhoLikedParams = {}
144
+ ): Promise<WhoLikedResponse> {
145
+ const httpClient = new HttpClient(globalConfig.baseUrl)
146
+ const queryObj: Record<string, string> = {
147
+ brand,
148
+ ...Object.fromEntries(
149
+ Object.entries({ page: 1, limit: 20, ...params })
150
+ .filter(([_, v]) => v !== undefined && v !== null)
151
+ .map(([k, v]) => [k, String(v)])
152
+ ),
153
+ }
154
+ const query = new URLSearchParams(queryObj).toString()
155
+ return httpClient.get<WhoLikedResponse>(`${baseUrl}/v1/posts/${postId}/likes?${query}`)
156
+ }
157
+
131
158
  /**
132
159
  * Unlike a forum post.
133
160
  *
@@ -1,3 +1,26 @@
1
+ export interface WhoLikedParams {
2
+ page?: number
3
+ limit?: number
4
+ }
5
+
6
+ export interface Liker {
7
+ user_id: number
8
+ display_name: string
9
+ profile_picture_url: string | null
10
+ access_level: string
11
+ liked_at: string
12
+ liked_at_diff: string | null
13
+ }
14
+
15
+ export interface WhoLikedResponse {
16
+ data: Liker[]
17
+ meta: {
18
+ total: number
19
+ current_page: number
20
+ last_page: number
21
+ }
22
+ }
23
+
1
24
  export interface ForumUser {
2
25
  id: number
3
26
  display_name: string
@@ -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) {