musora-content-services 2.107.3 → 2.107.5

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,9 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(rg:*)",
5
+ "Bash(npm run lint:*)"
6
+ ],
7
+ "deny": []
8
+ }
9
+ }
package/CHANGELOG.md CHANGED
@@ -2,6 +2,22 @@
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.107.5](https://github.com/railroadmedia/musora-content-services/compare/v2.107.4...v2.107.5) (2025-12-29)
6
+
7
+
8
+ ### Bug Fixes
9
+
10
+ * establishes a positive progress validation ([#672](https://github.com/railroadmedia/musora-content-services/issues/672)) ([e9bc211](https://github.com/railroadmedia/musora-content-services/commit/e9bc211659262b282e1073c2746c3c8824371c35))
11
+
12
+ ### [2.107.4](https://github.com/railroadmedia/musora-content-services/compare/v2.107.1...v2.107.4) (2025-12-22)
13
+
14
+
15
+ ### Bug Fixes
16
+
17
+ * **AGI:** contentType optional ([deae293](https://github.com/railroadmedia/musora-content-services/commit/deae2934c26ea0b9ee4d68c736fbb5753616a5e9))
18
+ * **AGI:** return children on AGI lessons functions ([#671](https://github.com/railroadmedia/musora-content-services/issues/671)) ([92ed791](https://github.com/railroadmedia/musora-content-services/commit/92ed791ce563aa4f1bf16cb41d2b6fe421504064))
19
+ * **onboarding:** call BE for recommendation ([#670](https://github.com/railroadmedia/musora-content-services/issues/670)) ([a876736](https://github.com/railroadmedia/musora-content-services/commit/a8767363ff3428638bde477cd6c30e5bf61179b2))
20
+
5
21
  ### [2.107.3](https://github.com/railroadmedia/musora-content-services/compare/v2.107.2...v2.107.3) (2025-12-19)
6
22
 
7
23
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "musora-content-services",
3
- "version": "2.107.3",
3
+ "version": "2.107.5",
4
4
  "description": "A package for Musoras content services ",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
package/src/index.d.ts CHANGED
@@ -155,6 +155,7 @@ import {
155
155
  createPost,
156
156
  deletePost,
157
157
  fetchCommunityGuidelines,
158
+ fetchPost,
158
159
  fetchPosts,
159
160
  jumpToPost,
160
161
  likePost,
@@ -168,6 +169,7 @@ import {
168
169
  deleteThread,
169
170
  fetchFollowedThreads,
170
171
  fetchLatestThreads,
172
+ fetchThread,
171
173
  fetchThreads,
172
174
  followThread,
173
175
  lockThread,
@@ -310,6 +312,14 @@ import {
310
312
  jumpToContinueContent
311
313
  } from './services/sanity.js';
312
314
 
315
+ import {
316
+ generateCommentUrl,
317
+ generateContentUrl,
318
+ generateContentUrlWithDomain,
319
+ generateForumPostUrl,
320
+ generatePlaylistUrl
321
+ } from './services/urlBuilder.ts';
322
+
313
323
  import {
314
324
  confirmEmailChange,
315
325
  deleteAccount,
@@ -543,6 +553,7 @@ declare module 'musora-content-services' {
543
553
  fetchPlayAlongsCount,
544
554
  fetchPlaylist,
545
555
  fetchPlaylistItems,
556
+ fetchPost,
546
557
  fetchPosts,
547
558
  fetchRecent,
548
559
  fetchRecentActivitiesActiveTabs,
@@ -563,6 +574,7 @@ declare module 'musora-content-services' {
563
574
  fetchSongById,
564
575
  fetchSongsInProgress,
565
576
  fetchTabData,
577
+ fetchThread,
566
578
  fetchThreads,
567
579
  fetchTopComment,
568
580
  fetchTopLevelParentId,
@@ -580,6 +592,11 @@ declare module 'musora-content-services' {
580
592
  findIncompleteLesson,
581
593
  followThread,
582
594
  generateAuthSessionUrl,
595
+ generateCommentUrl,
596
+ generateContentUrl,
597
+ generateContentUrlWithDomain,
598
+ generateForumPostUrl,
599
+ generatePlaylistUrl,
583
600
  getActiveDiscussions,
584
601
  getActivePath,
585
602
  getAllCompleted,
package/src/index.js CHANGED
@@ -159,6 +159,7 @@ import {
159
159
  createPost,
160
160
  deletePost,
161
161
  fetchCommunityGuidelines,
162
+ fetchPost,
162
163
  fetchPosts,
163
164
  jumpToPost,
164
165
  likePost,
@@ -172,6 +173,7 @@ import {
172
173
  deleteThread,
173
174
  fetchFollowedThreads,
174
175
  fetchLatestThreads,
176
+ fetchThread,
175
177
  fetchThreads,
176
178
  followThread,
177
179
  lockThread,
@@ -314,6 +316,14 @@ import {
314
316
  jumpToContinueContent
315
317
  } from './services/sanity.js';
316
318
 
319
+ import {
320
+ generateCommentUrl,
321
+ generateContentUrl,
322
+ generateContentUrlWithDomain,
323
+ generateForumPostUrl,
324
+ generatePlaylistUrl
325
+ } from './services/urlBuilder.ts';
326
+
317
327
  import {
318
328
  confirmEmailChange,
319
329
  deleteAccount,
@@ -542,6 +552,7 @@ export {
542
552
  fetchPlayAlongsCount,
543
553
  fetchPlaylist,
544
554
  fetchPlaylistItems,
555
+ fetchPost,
545
556
  fetchPosts,
546
557
  fetchRecent,
547
558
  fetchRecentActivitiesActiveTabs,
@@ -562,6 +573,7 @@ export {
562
573
  fetchSongById,
563
574
  fetchSongsInProgress,
564
575
  fetchTabData,
576
+ fetchThread,
565
577
  fetchThreads,
566
578
  fetchTopComment,
567
579
  fetchTopLevelParentId,
@@ -579,6 +591,11 @@ export {
579
591
  findIncompleteLesson,
580
592
  followThread,
581
593
  generateAuthSessionUrl,
594
+ generateCommentUrl,
595
+ generateContentUrl,
596
+ generateContentUrlWithDomain,
597
+ generateForumPostUrl,
598
+ generatePlaylistUrl,
582
599
  getActiveDiscussions,
583
600
  getActivePath,
584
601
  getAllCompleted,
@@ -469,6 +469,13 @@ export async function contentStatusReset(contentId, collection = null) {
469
469
  }
470
470
 
471
471
  async function saveContentProgress(contentId, collection, progress, currentSeconds) {
472
+
473
+ // filter out contentIds that are setting progress lower than existing
474
+ const contentIdProgress = await getProgressDataByIds([contentId], collection)
475
+ if (progress <= contentIdProgress[contentId].progress) {
476
+ return
477
+ }
478
+
472
479
  const response = await db.contentProgress.recordProgress(
473
480
  contentId,
474
481
  collection,
@@ -483,6 +490,15 @@ async function saveContentProgress(contentId, collection, progress, currentSecon
483
490
  const hierarchy = await getHierarchy(contentId, collection)
484
491
 
485
492
  const bubbledProgresses = await bubbleProgress(hierarchy, contentId, collection)
493
+
494
+ // filter out contentIds that are setting progress lower than existing
495
+ const existingProgresses = await getProgressDataByIds(Object.keys(bubbledProgresses), collection)
496
+ for (const [bubbledContentId, bubbledProgress] of Object.entries(bubbledProgresses)) {
497
+ if (bubbledProgress <= existingProgresses[bubbledContentId].progress) {
498
+ delete bubbledProgresses[bubbledContentId]
499
+ }
500
+ }
501
+
486
502
  // BE bubbling/trickling currently does not work, so we utilize non-tentative pushing when learning path collection
487
503
  await db.contentProgress.recordProgressMany(bubbledProgresses, collection, collection?.type !== COLLECTION_TYPE.LEARNING_PATH)
488
504
 
@@ -23,8 +23,26 @@ export interface CreatePostParams {
23
23
  * @throws {HttpError} - If the request fails.
24
24
  */
25
25
  export async function createPost(threadId: number, params: CreatePostParams): Promise<ForumPost> {
26
+ const { generateForumPostUrl } = await import('../urlBuilder.ts')
27
+ const { fetchThread } = await import('./threads.ts')
28
+
29
+ // Fetch thread to get category_id for URL generation
30
+ const thread = await fetchThread(threadId, params.brand)
31
+
32
+ // Generate forum post URL
33
+ const contentUrl = generateForumPostUrl({
34
+ brand: params.brand,
35
+ thread: {
36
+ category_id: thread.category_id,
37
+ id: threadId
38
+ }
39
+ }, false)
40
+
26
41
  const httpClient = new HttpClient(globalConfig.baseUrl)
27
- return httpClient.post<ForumPost>(`${baseUrl}/v1/threads/${threadId}/posts`, params)
42
+ return httpClient.post<ForumPost>(`${baseUrl}/v1/threads/${threadId}/posts`, {
43
+ ...params,
44
+ content_url: contentUrl
45
+ })
28
46
  }
29
47
 
30
48
  /**
@@ -40,6 +58,19 @@ export async function updatePost(postId: number, params: CreatePostParams): Prom
40
58
  return httpClient.put<ForumPost>(`${baseUrl}/v1/posts/${postId}`, params)
41
59
  }
42
60
 
61
+ /**
62
+ * Fetches a single forum post by ID.
63
+ *
64
+ * @param {number} postId - The ID of the post to fetch.
65
+ * @param {string} brand - The brand context (e.g., "drumeo", "singeo").
66
+ * @returns {Promise<ForumPost>} - A promise that resolves to the forum post.
67
+ * @throws {HttpError} - If the HTTP request fails.
68
+ */
69
+ export async function fetchPost(postId: number, brand: string): Promise<ForumPost> {
70
+ const httpClient = new HttpClient(globalConfig.baseUrl)
71
+ return httpClient.get<ForumPost>(`${baseUrl}/v1/posts/${postId}?brand=${brand}`)
72
+ }
73
+
43
74
  export interface FetchPostParams {
44
75
  page?: number
45
76
  limit?: number
@@ -91,8 +122,25 @@ export async function fetchPosts(
91
122
  * @throws {HttpError} - If the request fails.
92
123
  */
93
124
  export async function likePost(postId: number, brand: string): Promise<void> {
125
+ const { generateForumPostUrl } = await import('../urlBuilder.ts')
126
+
127
+ // Fetch post to get thread info for URL generation
128
+ const post = await fetchPost(postId, brand)
129
+
130
+ // Generate forum post URL
131
+ const contentUrl = generateForumPostUrl({
132
+ brand,
133
+ thread: {
134
+ category_id: post.thread.category_id,
135
+ id: post.thread.id
136
+ }
137
+ }, false)
138
+
94
139
  const httpClient = new HttpClient(globalConfig.baseUrl)
95
- return httpClient.post<void>(`${baseUrl}/v1/posts/${postId}/likes`, { brand })
140
+ return httpClient.post<void>(`${baseUrl}/v1/posts/${postId}/likes`, {
141
+ brand,
142
+ content_url: contentUrl
143
+ })
96
144
  }
97
145
 
98
146
  /**
@@ -89,6 +89,19 @@ export async function markThreadAsRead(threadId: number, brand: string): Promise
89
89
  return httpClient.put<void>(`${baseUrl}/v1/threads/${threadId}/read?brand=${brand}`, {})
90
90
  }
91
91
 
92
+ /**
93
+ * Fetches a single forum thread by ID.
94
+ *
95
+ * @param {number} threadId - The ID of the thread to fetch.
96
+ * @param {string} brand - The brand context (e.g., "drumeo", "singeo").
97
+ * @returns {Promise<ForumThread>} - A promise that resolves to the forum thread.
98
+ * @throws {HttpError} - If the HTTP request fails.
99
+ */
100
+ export async function fetchThread(threadId: number, brand: string): Promise<ForumThread> {
101
+ const httpClient = new HttpClient(globalConfig.baseUrl)
102
+ return httpClient.get<ForumThread>(`${baseUrl}/v1/threads/${threadId}?brand=${brand}`)
103
+ }
104
+
92
105
  export interface FetchThreadParams {
93
106
  is_followed?: boolean,
94
107
  page?: number,
@@ -249,8 +249,38 @@ export async function restoreComment(commentId) {
249
249
  * @returns {Promise<*|null>}
250
250
  */
251
251
  export async function replyToComment(commentId, comment) {
252
+ const { generateCommentUrl } = await import('./urlBuilder.ts')
253
+ const { fetchByRailContentIds } = await import('./sanity.js')
254
+
255
+ // Fetch parent comment to get content info
256
+ const parentComment = await fetchComment(commentId)
257
+
258
+ if (!parentComment?.content) {
259
+ const url = `/api/content/v1/comments/${commentId}/reply`
260
+ return await POST(url, { comment })
261
+ }
262
+
263
+ // Fetch content from Sanity to get parentId and correct type
264
+ const contents = await fetchByRailContentIds([parentComment.content.id])
265
+ const content = contents?.[0]
266
+
267
+ // Generate content URL
268
+ const contentUrl = content ? generateCommentUrl({
269
+ id: commentId,
270
+ content: {
271
+ id: content.id,
272
+ type: content.type,
273
+ parentId: content.parentId || content.parent_id,
274
+ brand: content.brand
275
+ }
276
+ }, false) : null
277
+
278
+ const data = {
279
+ comment: comment,
280
+ ...(contentUrl && { content_url: contentUrl })
281
+ }
252
282
  const url = `/api/content/v1/comments/${commentId}/reply`
253
- return await POST(url, { comment })
283
+ return await POST(url, data)
254
284
  }
255
285
 
256
286
  /**
@@ -259,8 +289,28 @@ export async function replyToComment(commentId, comment) {
259
289
  * @returns {Promise<*|null>}
260
290
  */
261
291
  export async function createComment(railcontentId, comment) {
292
+ const { generateContentUrl } = await import('./urlBuilder.ts')
293
+ const { fetchByRailContentIds } = await import('./sanity.js')
294
+
295
+ // Fetch content to get type and brand info
296
+ const contents = await fetchByRailContentIds([railcontentId])
297
+ const content = contents?.[0]
298
+
299
+ // Generate content URL
300
+ const contentUrl = content ? generateContentUrl({
301
+ id: content.id,
302
+ type: content.type,
303
+ parentId: content.parentId || content.parent_id,
304
+ brand: content.brand
305
+ }) : null
306
+
307
+ const data = {
308
+ comment: comment,
309
+ content_id: railcontentId,
310
+ ...(contentUrl && { content_url: contentUrl })
311
+ }
262
312
  const url = `/api/content/v1/comments/store`
263
- return await POST(url, { comment, content_id: railcontentId })
313
+ return await POST(url, data)
264
314
  }
265
315
 
266
316
  /**
@@ -286,8 +336,35 @@ export async function unassignModeratorToComment(commentId) {
286
336
  * @returns {Promise<*|null>}
287
337
  */
288
338
  export async function likeComment(commentId) {
339
+ const { generateCommentUrl } = await import('./urlBuilder.ts')
340
+ const { fetchByRailContentIds } = await import('./sanity.js')
341
+
342
+ // Fetch comment to get content info
343
+ const comment = await fetchComment(commentId)
344
+
345
+ if (!comment?.content) {
346
+ const url = `/api/content/v1/comments/${commentId}/like`
347
+ return await POST(url, null)
348
+ }
349
+
350
+ // Fetch content from Sanity to get parentId and correct type
351
+ const contents = await fetchByRailContentIds([comment.content.id])
352
+ const content = contents?.[0]
353
+
354
+ // Generate content URL
355
+ const contentUrl = content ? generateCommentUrl({
356
+ id: commentId,
357
+ content: {
358
+ id: content.id,
359
+ type: content.type,
360
+ parentId: content.parentId || content.parent_id,
361
+ brand: content.brand
362
+ }
363
+ }, false) : null
364
+
289
365
  const url = `/api/content/v1/comments/${commentId}/like`
290
- return await POST(url, null)
366
+ const data = contentUrl ? { content_url: contentUrl } : {}
367
+ return await POST(url, data)
291
368
  }
292
369
 
293
370
  /**
@@ -11,6 +11,10 @@ import { HttpClient } from '../../infrastructure/http/HttpClient'
11
11
  import { globalConfig } from '../config.js'
12
12
  import { ReportResponse, ReportableType, IssueTypeMap, ReportIssueOption } from './types'
13
13
  import { Brands } from '../../lib/brands'
14
+ import { generateContentUrl, generatePlaylistUrl, generateForumPostUrl, generateCommentUrl } from '../urlBuilder.ts'
15
+ import {fetchByRailContentId} from "../../index";
16
+ import {fetchByRailContentIds} from "../sanity";
17
+ import {addContextToContent} from "../contentAggregator";
14
18
 
15
19
  /**
16
20
  * Parameters for submitting a report with type-safe issue values
@@ -26,6 +30,14 @@ export type ReportParams<T extends ReportableType = ReportableType> = {
26
30
  details?: string
27
31
  /** Brand context (required: drumeo, pianote, guitareo, singeo, playbass) */
28
32
  brand: Brands | string
33
+ /** Full URL to the reported content (generated via urlBuilder) */
34
+ contentUrl?: string
35
+ /** Content data for URL generation (only needed if contentUrl not provided) */
36
+ content?: {
37
+ id: number
38
+ type: string
39
+ parentId?: number
40
+ }
29
41
  }
30
42
 
31
43
  /**
@@ -86,12 +98,68 @@ export async function report<T extends ReportableType>(
86
98
  requestBody.details = params.details
87
99
  }
88
100
 
89
- const response = await httpClient.post<ReportResponse>(
101
+ // Generate content_url for reports (relative URL - backend adds domain)
102
+ if (params.type === 'content') {
103
+ // Fetch content and add navigateTo for courses/packs/etc
104
+ const contents = await addContextToContent(
105
+ fetchByRailContentIds,
106
+ [params.id],
107
+ { addNavigateTo: true }
108
+ )
109
+ const content = contents?.[0]
110
+
111
+ if (content) {
112
+ requestBody.content_url = generateContentUrl({
113
+ id: content.id,
114
+ type: content.type,
115
+ parentId: content.parentId || content.parent_id,
116
+ brand: content.brand,
117
+ navigateTo: content.navigateTo
118
+ })
119
+ }
120
+ } else if (params.type === 'playlist') {
121
+ requestBody.content_url = generatePlaylistUrl({
122
+ id: params.id
123
+ })
124
+ } else if (params.type === 'forum_post') {
125
+ const { fetchPost } = await import('../forums/posts.ts')
126
+ const post = await fetchPost(params.id, params.brand)
127
+
128
+ if (post?.thread) {
129
+ requestBody.content_url = generateForumPostUrl({
130
+ brand: params.brand,
131
+ thread: {
132
+ category_id: post.thread.category_id,
133
+ id: post.thread.id
134
+ }
135
+ })
136
+ }
137
+ } else if (params.type === 'comment') {
138
+ const { fetchComment } = await import('../railcontent.js')
139
+ const comment = await fetchComment(params.id)
140
+
141
+ if (comment?.content) {
142
+ const contents = await fetchByRailContentIds([comment.content.id])
143
+ const content = contents?.[0]
144
+
145
+ if (content) {
146
+ requestBody.content_url = generateCommentUrl({
147
+ id: comment.id,
148
+ content: {
149
+ id: content.id,
150
+ type: content.type,
151
+ parentId: content.parentId || content.parent_id,
152
+ brand: params.brand
153
+ }
154
+ })
155
+ }
156
+ }
157
+ }
158
+
159
+ return await httpClient.post<ReportResponse>(
90
160
  '/api/user-reports/v1/reports',
91
161
  requestBody
92
162
  )
93
-
94
- return response
95
163
  }
96
164
 
97
165
  /**
@@ -927,7 +927,7 @@ export async function fetchLessonContent(railContentId, { addParent = false } =
927
927
  "instructor": ${instructorField},
928
928
  ${assignmentsField}
929
929
  video,
930
- length_in_seconds,
930
+ "length_in_seconds": coalesce(soundslice[0].soundslice_length_in_second, length_in_seconds),
931
931
  mp3_no_drums_no_click_url,
932
932
  mp3_no_drums_yes_click_url,
933
933
  mp3_yes_drums_no_click_url,
@@ -0,0 +1,297 @@
1
+ /**
2
+ * @module UrlBuilder
3
+ * @description URL generation for content across Musora platform
4
+ *
5
+ * This is the SINGLE SOURCE OF TRUTH for URL generation.
6
+ * Used by:
7
+ * - musora-platform-frontend (via import)
8
+ * - Mobile apps (via import)
9
+ * - Backend receives these URLs from frontend and stores them in DB
10
+ *
11
+ * Port of: musora-platform-frontend/src/shared/utils/content.utils.ts:generateContentUrl
12
+ * Related: musora-platform-backend/app/Modules/Content/Builders/UrlBuilder.php (deprecated fallback)
13
+ */
14
+
15
+ import { globalConfig } from './config.js'
16
+ import { Brands } from '../lib/brands.js'
17
+
18
+ /**
19
+ * Brand type - accepts enum values or string
20
+ */
21
+ export type Brand = Brands | string
22
+
23
+ /**
24
+ * Parameters for generating content URLs
25
+ */
26
+ export interface ContentUrlParams {
27
+ /** Content ID (required) */
28
+ id: number | string
29
+ /** Content type (required) */
30
+ type: string
31
+ /** Parent content ID (optional) */
32
+ parentId?: number
33
+ /** Navigation target (optional) */
34
+ navigateTo?: {
35
+ id: number
36
+ }
37
+ /** Brand (drumeo, pianote, guitareo, singeo, playbass) */
38
+ brand?: Brand
39
+ }
40
+
41
+ /**
42
+ * Forum post object for URL generation
43
+ */
44
+ export interface ForumPostUrlParams {
45
+ /** Brand (drumeo, pianote, etc) */
46
+ brand: Brand
47
+ /** Thread information */
48
+ thread: {
49
+ /** Thread category ID */
50
+ category_id: number
51
+ /** Thread ID */
52
+ id: number
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Playlist object for URL generation
58
+ */
59
+ export interface PlaylistUrlParams {
60
+ /** Playlist ID */
61
+ id: number
62
+ }
63
+
64
+ /**
65
+ * Comment object for URL generation
66
+ */
67
+ export interface CommentUrlParams {
68
+ /** Comment ID */
69
+ id: number
70
+ /** Content information */
71
+ content: {
72
+ /** Content ID */
73
+ id: number
74
+ /** Content type */
75
+ type: string
76
+ /** Parent content ID (optional) */
77
+ parentId?: number
78
+ /** Brand */
79
+ brand: Brand
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Generate a frontend URL for content
85
+ *
86
+ * @param params - Content parameters
87
+ * @returns The generated URL path
88
+ *
89
+ * @example
90
+ * generateContentUrl({ id: 123, type: 'song', brand: 'drumeo' })
91
+ * // Returns: "/drumeo/songs/transcription/123"
92
+ *
93
+ * @example
94
+ * generateContentUrl({ id: 456, type: 'course-part', parentId: 789, brand: 'pianote' })
95
+ * // Returns: "/pianote/lessons/course/789/456"
96
+ *
97
+ * @example
98
+ * generateContentUrl({ id: 123, type: 'pack-bundle', navigateTo: { id: 456 }, brand: 'guitareo' })
99
+ * // Returns: "/guitareo/lessons/pack/123/456"
100
+ */
101
+ export function generateContentUrl({
102
+ id,
103
+ type,
104
+ parentId,
105
+ navigateTo,
106
+ brand = 'drumeo',
107
+ }: ContentUrlParams): string {
108
+ // Special case: method homepage
109
+ if (type === 'method') {
110
+ return `/${brand}/method`
111
+ }
112
+
113
+ // Return fallback if required params missing
114
+ if (!id || !type) {
115
+ return '#'
116
+ }
117
+
118
+ // Special cases that don't follow the standard pattern
119
+ if (type === 'live') {
120
+ return `/${brand}/lessons/${id}/live`
121
+ }
122
+
123
+ if (type === 'pack') {
124
+ return `/${brand}/lessons/pack/overview/${id}`
125
+ }
126
+
127
+ if (type === 'pack-bundle') {
128
+ if (navigateTo?.id) {
129
+ return `/${brand}/lessons/pack/${id}/${navigateTo.id}`
130
+ }
131
+ // Fallback to overview if navigateTo is missing
132
+ return `/${brand}/lessons/pack/overview/${id}`
133
+ }
134
+
135
+ // Helper function to build URL with common parameters
136
+ const buildUrl = (typeSegments: string[]): string => {
137
+ const contentId = navigateTo ? `${id}/${navigateTo.id}` : id
138
+ const parentSegment = parentId ? `/${parentId}` : ''
139
+ return `/${brand}/${typeSegments.join('/')}${parentSegment}/${contentId}`
140
+ }
141
+
142
+ // Determine page type (songs, method, or lessons)
143
+ const songTypes = [
144
+ 'song',
145
+ 'song-tutorial',
146
+ 'song-tutorial-lesson',
147
+ 'transcription',
148
+ 'play-along',
149
+ 'jam-track',
150
+ ]
151
+
152
+ const methodTypes = ['learning-path-v2', 'learning-path-lesson-v2']
153
+
154
+ let pageType: string
155
+ if (songTypes.includes(type)) {
156
+ pageType = 'songs'
157
+ } else if (methodTypes.includes(type)) {
158
+ pageType = 'method'
159
+ } else {
160
+ pageType = 'lessons'
161
+ }
162
+
163
+ // Content type routing - maps specific types to URL segments
164
+ const contentTypeRoutes: Record<string, string> = {
165
+ // Lesson types
166
+ 'course-lesson': 'course',
167
+ 'guided-course-lesson': 'course',
168
+ 'guided-course': 'course',
169
+ 'pack-bundle-lesson': 'pack',
170
+ 'documentary-lesson': 'documentary',
171
+ 'skill-pack-lesson': 'skill-pack',
172
+
173
+ // Method types
174
+ 'learning-path-lesson-v2': 'lesson',
175
+ 'learning-path-v2': 'lesson',
176
+
177
+ // Song types
178
+ song: 'transcription',
179
+ 'song-tutorial': 'tutorial',
180
+ 'song-tutorial-lesson': 'tutorial',
181
+ }
182
+
183
+ // Use specific route if available, otherwise fall back to type as-is
184
+ const contentTypeSegment = contentTypeRoutes[type] || type
185
+
186
+ return buildUrl([pageType, contentTypeSegment])
187
+ }
188
+
189
+ /**
190
+ * Generate a full URL with domain
191
+ *
192
+ * @param params - Content parameters (same as generateContentUrl)
193
+ * @returns Full URL with domain from globalConfig.frontendUrl
194
+ *
195
+ * @example
196
+ * generateContentUrlWithDomain({ id: 123, type: 'song' })
197
+ * // Returns: "https://www.musora.com/drumeo/songs/transcription/123"
198
+ */
199
+ export function generateContentUrlWithDomain(params: ContentUrlParams): string {
200
+ const path = generateContentUrl(params)
201
+
202
+ if (path === '#') {
203
+ return '#'
204
+ }
205
+
206
+ return globalConfig.frontendUrl + path
207
+ }
208
+
209
+ /**
210
+ * Generate URL for a forum post
211
+ *
212
+ * @param post - Forum post object
213
+ * @param withDomain - Include domain from globalConfig.frontendUrl
214
+ * @returns Forum post URL
215
+ *
216
+ * @example
217
+ * generateForumPostUrl({ brand: 'drumeo', thread: { category_id: 12, id: 456 }})
218
+ * // Returns: "/drumeo/forums/threads/12/456"
219
+ */
220
+ export function generateForumPostUrl(
221
+ post: ForumPostUrlParams,
222
+ withDomain: boolean = false
223
+ ): string {
224
+ const path = `/${post.brand}/forums/threads/${post.thread.category_id}/${post.thread.id}`
225
+
226
+ if (withDomain) {
227
+ return globalConfig.frontendUrl + path
228
+ }
229
+
230
+ return path
231
+ }
232
+
233
+ /**
234
+ * Generate URL for a user playlist
235
+ *
236
+ * @param playlist - Playlist object
237
+ * @param withDomain - Include domain from globalConfig.frontendUrl
238
+ * @returns Playlist URL
239
+ *
240
+ * @example
241
+ * generatePlaylistUrl({ id: 123 })
242
+ * // Returns: "/playlists/123"
243
+ */
244
+ export function generatePlaylistUrl(
245
+ playlist: PlaylistUrlParams,
246
+ withDomain: boolean = false
247
+ ): string {
248
+ const path = `/playlists/${playlist.id}`
249
+
250
+ if (withDomain) {
251
+ return globalConfig.frontendUrl + path
252
+ }
253
+
254
+ return path
255
+ }
256
+
257
+ /**
258
+ * Generate URL for a comment (content URL with anchor)
259
+ *
260
+ * @param comment - Comment object
261
+ * @param withDomain - Include domain from globalConfig.frontendUrl
262
+ * @returns Comment URL with anchor
263
+ *
264
+ * @example
265
+ * generateCommentUrl({
266
+ * id: 789,
267
+ * content: { id: 123, type: 'song', brand: 'drumeo' }
268
+ * })
269
+ * // Returns: "/drumeo/songs/transcription/123#comment-789"
270
+ */
271
+ export function generateCommentUrl(
272
+ comment: CommentUrlParams,
273
+ withDomain: boolean = false
274
+ ): string {
275
+ if (!comment.content) {
276
+ return '#'
277
+ }
278
+
279
+ const contentUrl = generateContentUrl({
280
+ id: comment.content.id,
281
+ type: comment.content.type,
282
+ parentId: comment.content.parentId,
283
+ brand: comment.content.brand,
284
+ })
285
+
286
+ if (contentUrl === '#') {
287
+ return '#'
288
+ }
289
+
290
+ const path = `${contentUrl}#comment-${comment.id}`
291
+
292
+ if (withDomain) {
293
+ return globalConfig.frontendUrl + path
294
+ }
295
+
296
+ return path
297
+ }