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.
- package/.claude/settings.local.json +9 -0
- package/CHANGELOG.md +16 -0
- package/package.json +1 -1
- package/src/index.d.ts +17 -0
- package/src/index.js +17 -0
- package/src/services/contentProgress.js +16 -0
- package/src/services/forums/posts.ts +50 -2
- package/src/services/forums/threads.ts +13 -0
- package/src/services/railcontent.js +80 -3
- package/src/services/reporting/reporting.ts +71 -3
- package/src/services/sanity.js +1 -1
- package/src/services/urlBuilder.ts +297 -0
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
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`,
|
|
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`, {
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
/**
|
package/src/services/sanity.js
CHANGED
|
@@ -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
|
+
}
|