/**
* @module Sanity-Services
*/
import {
artistOrInstructorName,
assignmentsField,
chapterField,
coachLessonsTypes,
contentTypeConfig,
DEFAULT_FIELDS,
descriptionField,
filtersToGroq,
getChildFieldsForContentType,
getFieldsForContentType,
getFieldsForContentTypeWithFilteredChildren,
getIntroVideoFields,
getNewReleasesTypes,
getUpcomingEventsTypes,
instructorField,
lessonTypesMapping,
individualLessonsTypes,
coursesLessonTypes,
skillLessonTypes,
entertainmentLessonTypes,
filterTypes,
tutorialsLessonTypes,
transcriptionsLessonTypes,
playAlongLessonTypes,
jamTrackLessonTypes,
resourcesField,
showsTypes,
SONG_TYPES,
SONG_TYPES_WITH_CHILDREN,
liveFields,
postProcessBadge,
parentField,
grandParentField, parentRecentTypes,
} from '../contentTypeConfig.js'
import { fetchSimilarItems } from './recommendations.js'
import { getSongType, processMetadata, ALWAYS_VISIBLE_TABS, CONTENT_STATUSES } from '../contentMetaData.js'
import { GET } from '../infrastructure/http/HttpClient.ts'
import { globalConfig } from './config.js'
import { arrayToStringRepresentation, FilterBuilder } from '../filterBuilder.js'
import { getPermissionsAdapter } from './permissions/index.ts'
import {getAllCompleted, getAllCompletedByIds, getAllStarted, getAllStartedOrCompleted} from './contentProgress.js'
import { fetchRecentActivitiesActiveTabs } from './userActivity.js'
import { query } from '../lib/sanity/query'
import { Filters as f } from '../lib/sanity/filter'
import { COLLECTION_TYPE } from './sync/models/ContentProgress'
/**
* Exported functions that are excluded from index generation.
*
* @type {string[]}
*/
const excludeFromGeneratedIndex = ['fetchRelatedByLicense']
/**
* Mapping from tab names to their underlying Sanity content types.
* Used to determine if a tab has any content available.
* @type {Object.<string, string[]>}
*/
const TAB_TO_CONTENT_TYPES = {
'Single Lessons': individualLessonsTypes,
Courses: coursesLessonTypes,
'Skill Packs': skillLessonTypes,
Entertainment: entertainmentLessonTypes,
Tutorials: tutorialsLessonTypes,
Transcriptions: transcriptionsLessonTypes,
'Sheet Music': transcriptionsLessonTypes,
Tabs: transcriptionsLessonTypes,
'Play-Alongs': playAlongLessonTypes,
'Jam Tracks': jamTrackLessonTypes,
}
/**
* Fetch a song by its document ID from Sanity.
*
* @param {string} documentId - The ID of the document to fetch.
* @returns {Promise<Object|null>} - A promise that resolves to an object containing the song data or null if not found.
*
* @example
* fetchSongById('abc123')
* .then(song => console.log(song))
* .catch(error => console.error(error));
*/
export async function fetchSongById(documentId) {
const fields = getFieldsForContentType('song')
const filterParams = {}
const query = await buildQuery(
`_type == "song" && railcontent_id == ${documentId}`,
filterParams,
fields,
{
isSingle: true,
}
)
return fetchSanity(query, false)
}
/**
* fetches from Sanity all content marked for removal next quarter
*
* @string brand
* @number pageNumber
* @number contentPerPage
* @returns {Promise<Object|null>}
*/
export async function fetchLeaving(brand, { pageNumber = 1, contentPerPage = 20 } = {}) {
const today = new Date()
const isoDateOnly = getDateOnly(today)
const filterString = `brand == '${brand}' && quarter_removed > '${isoDateOnly}'`
const startEndOrder = getQueryFromPage(pageNumber, contentPerPage)
const sortOrder = {
sortOrder: 'quarter_removed asc, published_on desc, id desc',
start: startEndOrder['start'],
end: startEndOrder['end'],
}
const query = await buildQuery(
filterString,
{ pullFutureContent: false, availableContentStatuses: CONTENT_STATUSES.PUBLISHED_ONLY },
getFieldsForContentType('leaving'),
sortOrder
)
return fetchSanity(query, true)
}
/**
* fetches from Sanity all content marked for return next quarter
*
* @string brand
* @number pageNumber
* @number contentPerPage
* @returns {Promise<Object|null>}
*/
export async function fetchReturning(brand, { pageNumber = 1, contentPerPage = 20 } = {}) {
const today = new Date()
const isoDateOnly = getDateOnly(today)
const filterString = `brand == '${brand}' && quarter_published >= '${isoDateOnly}'`
const startEndOrder = getQueryFromPage(pageNumber, contentPerPage)
const sortOrder = {
sortOrder: 'quarter_published asc, published_on desc, id desc',
start: startEndOrder['start'],
end: startEndOrder['end'],
}
const query = await buildQuery(
filterString,
{ pullFutureContent: true, availableContentStatuses: CONTENT_STATUSES.DRAFT_ONLY },
getFieldsForContentType('returning'),
sortOrder
)
return fetchSanity(query, true)
}
/**
* fetches from Sanity all songs coming soon (new) next quarter
*
* @string brand
* @number pageNumber
* @number contentPerPage
* @returns {Promise<Object|null>}
*/
export async function fetchComingSoon(brand, { pageNumber = 1, contentPerPage = 20 } = {}) {
const filterString = `brand == '${brand}' && _type == 'song'`
const startEndOrder = getQueryFromPage(pageNumber, contentPerPage)
const sortOrder = {
sortOrder: 'published_on desc, id desc',
start: startEndOrder['start'],
end: startEndOrder['end'],
}
const query = await buildQuery(
filterString,
{ getFutureContentOnly: true },
getFieldsForContentType(),
sortOrder
)
return fetchSanity(query, true)
}
/**
*
* @number page
* @returns {number[]}
*/
function getQueryFromPage(pageNumber, contentPerPage) {
const start = contentPerPage * (pageNumber - 1)
const end = contentPerPage * pageNumber
let result = []
result['start'] = start
result['end'] = end
return result
}
/**
* Fetch current number of artists for songs within a brand.
* @param {string} brand - The current brand.
* @returns {Promise<int|null>} - The fetched count of artists.
*/
export async function fetchSongArtistCount(brand) {
const filter = await new FilterBuilder(
`_type == "song" && brand == "${brand}" && references(^._id)`,
{ bypassPermissions: true }
).buildFilter()
const query = `
count(*[_type == "artist"]{
name,
"lessonsCount": count(*[${filter}])
}[lessonsCount > 0])`
return fetchSanity(query, true, { processNeedAccess: false })
}
export async function fetchPlayAlongsCount(
brand,
{ searchTerm, includedFields, progressIds, progress }
) {
const searchFilter = searchTerm
? `&& (artist->name match "${searchTerm}*" || instructor[]->name match "${searchTerm}*" || title match "${searchTerm}*" || name match "${searchTerm}*")`
: ''
// Construct the included fields filter, replacing 'difficulty' with 'difficulty_string'
const includedFieldsFilter = includedFields.length > 0 ? filtersToGroq(includedFields) : ''
// limits the results to supplied progressIds for started & completed filters
const progressFilter = await getProgressFilter(progress, progressIds)
const query = `count(*[brand == '${brand}' && _type == "play-along" ${searchFilter} ${includedFieldsFilter} ${progressFilter} ]) `
return fetchSanity(query, true, { processNeedAccess: false })
}
/**
* Fetch related songs for a specific brand and song ID.
*
* @param {string} brand - The brand for which to fetch related songs.
* @param {string} songId - The ID of the song to find related songs for.
* @returns {Promise<Object|null>} - A promise that resolves to an array of related song objects or null if not found.
*
* @example
* fetchRelatedSongs('drumeo', '12345')
* .then(relatedSongs => console.log(relatedSongs))
* .catch(error => console.error(error));
*/
export async function fetchRelatedSongs(brand, songId) {
const now = getSanityDate(new Date())
const query = `
*[_type == "song" && railcontent_id == ${songId}]{
"entity": array::unique([
...(*[_type == "song" && brand == "${brand}" && railcontent_id != ${songId} && references(^.artist->_id)
&& (status in ['published'] || (status == 'scheduled' && defined(published_on) && published_on >= '${now}'))]{
"type": _type,
"id": railcontent_id,
"url": web_url_path,
"published_on": published_on,
status,
"image": thumbnail.asset->url,
"permission_id": permission_v2,
"fields": [
{
"key": "title",
"value": title
},
{
"key": "artist",
"value": artist->name
},
{
"key": "difficulty",
"value": difficulty
},
{
"key": "length_in_seconds",
"value": soundslice[0].soundslice_length_in_second
}
],
}[0...10]),
...(*[_type == "song" && brand == "${brand}" && railcontent_id != ${songId} && references(^.genre[]->_id)
&& (status in ['published'] || (status == 'scheduled' && defined(published_on) && published_on >= '${now}'))]{
"type": _type,
"id": railcontent_id,
"url": web_url_path,
"published_on": published_on,
"permission_id": permission_v2,
status,
"fields": [
{
"key": "title",
"value": title
},
{
"key": "artist",
"value": artist->name
},
{
"key": "difficulty",
"value": difficulty
},
{
"key": "length_in_seconds",
"value": soundslice[0].soundslice_length_in_second
}
],
"data": [{
"key": "thumbnail_url",
"value": thumbnail.asset->url
}]
}[0...10])
])[0...10]
}`
// Fetch the related songs data
return fetchSanity(query, false)
}
/**
* Fetch the latest new releases for a specific brand.
* @param {string} brand - The brand for which to fetch new releases.
* @returns {Promise<Object|null>} - The fetched new releases data or null if not found.
*/
export async function fetchNewReleases(
brand,
{ page = 1, limit = 20, sort = '-published_on' } = {}
) {
const newTypes = getNewReleasesTypes(brand)
const typesString = arrayToStringRepresentation(newTypes)
const start = (page - 1) * limit
const end = start + limit
const sortOrder = getSortOrder(sort, brand)
const now = getDateOnly()
const filter = `_type in ${typesString} && brand == '${brand}' && (status == 'published' && show_in_new_feed == true && published_on <= '${now}')`
let fields = await getFieldsForContentTypeWithFilteredChildren(null)
const entityFieldsString = `${fields}
"id": railcontent_id,
status,
title,
"image": thumbnail.asset->url,
"thumbnail": thumbnail.asset->url,
${artistOrInstructorName()},
"instructor": ${instructorField},
"artists": instructor[]->name,
difficulty,
difficulty_string,
length_in_seconds,
published_on,
"type": _type,
web_url_path,
"permission_id": permission_v2,
`
const query = buildRawQuery(filter, entityFieldsString, { sortOrder: sortOrder, start, end: end })
return fetchSanity(query, true)
}
/**
* Fetch upcoming events for a specific brand.
*
* @param {string} brand - The brand for which to fetch upcoming events.
* @returns {Promise<Object|null>} - A promise that resolves to an array of upcoming event objects or null if not found.
*
* @example
* fetchUpcomingEvents('drumeo', {
* page: 2,
* limit: 20,
* })
* .then(events => console.log(events))
* .catch(error => console.error(error));
*/
export async function fetchUpcomingEvents(brand, { page = 1, limit = 10 } = {}) {
const now = getSanityDate(new Date())
const start = (page - 1) * limit
const end = start + limit
const fields = `
"id": railcontent_id,
status,
title,
"image": thumbnail.asset->url,
"thumbnail": thumbnail.asset->url,
${artistOrInstructorName()},
"artists": instructor[]->name,
"instructor": ${instructorField},
difficulty,
difficulty_string,
length_in_seconds,
published_on,
"type": _type,
web_url_path,
"permission_id": permission_v2,
live_event_start_time,
live_event_end_time,
"isLive": live_event_start_time <= '${now}' && live_event_end_time >= '${now}'`
const query = buildRawQuery(
`defined(live_event_start_time) && (!defined(live_event_end_time) || live_event_end_time >= '${now}' ) && (brand == '${brand}' || live_global_event) && status in ['scheduled']`,
fields,
{
sortOrder: 'published_on asc',
start: start,
end: end,
}
)
return fetchSanity(query, true)
}
/**
* Fetch scheduled releases for a specific brand.
*
* @param {string} brand - The brand for which to fetch scheduled releasess.
* @returns {Promise<Object|null>} - A promise that resolves to an array of scheduled release objects or null if not found.
*
* @example
* fetchScheduledReleases('drumeo', {
* page: 2,
* limit: 20,
* })
* .then(content => console.log(content))
* .catch(error => console.error(error));
*/
export async function fetchScheduledReleases(brand, { page = 1, limit = 10 }) {
const upcomingTypes = getUpcomingEventsTypes(brand)
const newTypes = getNewReleasesTypes(brand)
const scheduledTypes = merge(upcomingTypes, newTypes)
const typesString = arrayJoinWithQuotes(scheduledTypes)
const now = getSanityDate(new Date())
const start = (page - 1) * limit
const end = start + limit
const query = `*[_type in [${typesString}] && brand == '${brand}' && status in ['published','scheduled'] && (!defined(live_event_end_time) || live_event_end_time < '${now}' ) && published_on > '${now}']{
"id": railcontent_id,
status,
title,
"image": thumbnail.asset->url,
"thumbnail": thumbnail.asset->url,
${artistOrInstructorName()},
"instructor": ${instructorField},
"artists": instructor[]->name,
difficulty,
difficulty_string,
length_in_seconds,
published_on,
"type": _type,
web_url_path,
"permission_id": permission_v2,
} | order(published_on asc)[${start}...${end}]`
return fetchSanity(query, true)
}
/**
* Fetch content by a specific Railcontent ID.
*
* @param {string} id - The Railcontent ID of the content to fetch.
* @param {string} contentType - The document type of content to fetch
* @returns {Promise<Object|null>} - A promise that resolves to the content object or null if not found.
*
* @example
* fetchByRailContentId('abc123')
* .then(content => console.log(content))
* .catch(error => console.error(error));
*/
export async function fetchByRailContentId(id, contentType) {
const fields = await getFieldsForContentTypeWithFilteredChildren(contentType, true)
const childrenFilter = await new FilterBuilder(``, { isChildrenFilter: true }).buildFilter()
const entityFieldsString = ` ${fields}
'child_count': coalesce(count(child[${childrenFilter}]->), 0) ,
'length_in_seconds': coalesce(
math::sum(
select(
child[${childrenFilter}]->length_in_seconds
)
),
length_in_seconds
),`
const query = buildRawQuery(
`railcontent_id == ${id} && _type == '${contentType}'`,
entityFieldsString,
{
isSingle: true,
}
)
return fetchSanity(query, false)
}
/**
* Fetch content by an array of Railcontent IDs.
*
* @param {Array<string|number>} ids - The array of Railcontent IDs of the content to fetch.
* @param {string} [contentType] - The content type the IDs to add needed fields to the response.
* @returns {Promise<Array<Object>|null>} - A promise that resolves to an array of content objects or null if not found.
*
* @example
* fetchByRailContentIds(['abc123', 'def456', 'ghi789'])
* .then(contents => console.log(contents))
* .catch(error => console.error(error));
*/
export async function fetchByRailContentIds(
ids,
contentType = undefined,
brand = undefined,
includePermissionsAndStatusFilter = false,
filterOptions = {}
) {
if (!ids?.length) {
return []
}
ids = [...new Set(ids.filter((item) => item !== null && item !== undefined))]
const idsString = ids.join(',')
const brandFilter = brand ? ` && brand == "${brand}"` : ''
const lessonCountFilter = await new FilterBuilder(`_id in ^.child[]._ref`, {
pullFutureContent: true,
}).buildFilter()
const fields = await getFieldsForContentTypeWithFilteredChildren(contentType, true)
const baseFilter = `railcontent_id in [${idsString}]${brandFilter}`
const finalFilter = includePermissionsAndStatusFilter
? await new FilterBuilder(baseFilter, filterOptions).buildFilter()
: baseFilter
const query = `*[
${finalFilter}
]{
${fields}
'lesson_count': coalesce(count(*[${lessonCountFilter}]), 0),
live_event_start_time,
live_event_end_time,
}`
const customPostProcess = (results) => {
const now = getSanityDate(new Date(), false)
const liveProcess = (result) => {
if (result.live_event_start_time && result.live_event_end_time) {
result.isLive = result.live_event_start_time <= now && result.live_event_end_time >= now
} else {
result.isLive = false
}
return result
}
return results.map(liveProcess)
}
const results = await fetchSanity(query, true, {
customPostProcess: customPostProcess,
processNeedAccess: true,
})
const sortFuction = function compare(a, b) {
const indexA = ids.indexOf(a['id'])
const indexB = ids.indexOf(b['id'])
if (indexA === indexB) return 0
if (indexA > indexB) return 1
return -1
}
// Sort results to match the order of the input IDs
const sortedResults = results?.sort(sortFuction) ?? null
return sortedResults
}
export async function fetchContentRows(brand, pageName, contentRowSlug) {
if (pageName === 'lessons') pageName = 'lesson'
if (pageName === 'songs') pageName = 'song'
const rowString = contentRowSlug ? ` && slug.current == "${contentRowSlug.toLowerCase()}"` : ''
const lessonCountFilter = await new FilterBuilder(`_id in ^.child[]._ref`, {
pullFutureContent: true,
showMembershipRestrictedContent: true,
}).buildFilter()
const childFilter = await new FilterBuilder('', {
isChildrenFilter: true,
showMembershipRestrictedContent: true,
}).buildFilter()
const query = `*[_type == 'recommended-content-row' && brand == '${brand}' && type == '${pageName}'${rowString}]{
brand,
name,
'slug': slug.current,
'content': content[${childFilter}]->{
'children': child[${childFilter}]->{ 'id': railcontent_id,
'type': _type, brand, 'thumbnail': thumbnail.asset->url,
'children': child[${childFilter}]->{'id': railcontent_id}, },
${getFieldsForContentType('tab-data')}
'lesson_count': coalesce(count(*[${lessonCountFilter}]), 0),
},
}`
return fetchSanity(query, true, { processNeedAccess: true })
}
/**
* Fetch all content for a specific brand and type with pagination, search, and grouping options.
* @param {string} brand - The brand for which to fetch content.
* @param {string} type - The content type to fetch (e.g., 'song', 'artist').
* @param {Object} params - Parameters for pagination, filtering, sorting, and grouping.
* @param {number} [params.page=1] - The page number for pagination.
* @param {number} [params.limit=10] - The number of items per page.
* @param {string} [params.searchTerm=""] - The search term to filter content by title or artist.
* @param {string} [params.sort="-published_on"] - The field to sort the content by.
* @param {Array<string>} [params.includedFields=[]] - The fields to include in the query.
* @param {string} [params.groupBy=""] - The field to group the results by (e.g., 'artist', 'genre').
* @param {Array<string>} [params.progressIds=undefined] - An array of railcontent IDs to filter the results by. Used for filtering by progress.
* @param {boolean} [params.useDefaultFields=true] - use the default sanity fields for content Type
* @param {Array<string>} [params.customFields=[]] - An array of sanity fields to include in the request
* @param {string} [params.progress="all"] - An string representing which progress filter to use ("all", "in progress", "complete", "not started").
* @returns {Promise<Object|null>} - The fetched content data or null if not found.
*
* @example
* fetchAll('drumeo', 'song', {
* page: 2,
* limit: 20,
* searchTerm: 'jazz',
* sort: '-popularity',
* includedFields: ['difficulty,Intermediate'],
* groupBy: 'artist',
* progressIds: [123, 321],
* useDefaultFields: false,
* customFields: ['is_house_coach', 'slug.current', "'instructors': instructor[]->name"],
* })
* .then(content => console.log(content))
* .catch(error => console.error(error));
*/
export async function fetchAll(
brand,
type,
{
page = 1,
limit = 10,
searchTerm = '',
sort = '-published_on',
includedFields = [],
groupBy = '',
progressIds = undefined,
useDefaultFields = true,
customFields = [],
progress = 'all',
onlyPublished = true
} = {}
) {
let config = contentTypeConfig[type] ?? {}
let additionalFields = config?.fields ?? []
let isGroupByOneToOne = (groupBy ? config?.relationships?.[groupBy]?.isOneToOne : false) ?? false
let webUrlPathType = config?.slug ?? type
const start = (page - 1) * limit
const end = start + limit
let bypassStatusAndPublishedValidation =
type == 'instructor' || groupBy == 'artist' || groupBy == 'genre' || groupBy == 'instructor'
let bypassPermissions = bypassStatusAndPublishedValidation
// Construct the type filter
let typeFilter
if (type === 'archives') {
typeFilter = `&& status == "archived"`
bypassStatusAndPublishedValidation = true
} else if (type === 'lessons' || type === 'songs') {
typeFilter = ``
} else {
typeFilter = type ? `&& _type == '${type}'` : ''
}
// Construct the search filter
const searchFilter = searchTerm
? groupBy !== ''
? `&& (^.name match "${searchTerm}*" || title match "${searchTerm}*")`
: `&& (artist->name match "${searchTerm}*" || instructor[]->name match "${searchTerm}*" || title match "${searchTerm}*" || name match "${searchTerm}*")`
: ''
// Construct the included fields filter, replacing 'difficulty' with 'difficulty_string'
const includedFieldsFilter = includedFields.length > 0 ? filtersToGroq(includedFields) : ''
// limits the results to supplied progressIds for started & completed filters
const progressFilter = await getProgressFilter(progress, progressIds)
// Determine the sort order
const sortOrder = getSortOrder(sort, brand, groupBy)
let fields = useDefaultFields
? customFields.concat(DEFAULT_FIELDS, additionalFields)
: customFields
let fieldsString = fields.join(',')
let customFilter = ''
if (type == 'instructor') {
customFilter = '&& coach_card_image != null'
}
if (onlyPublished) {
customFilter = ' && status == "published" '
}
// Determine the group by clause
let query = ''
let entityFieldsString = ''
let filter = ''
if (groupBy !== '' && isGroupByOneToOne) {
const webUrlPath = 'artists'
const lessonsFilter = `_type == '${type}' && brand == '${brand}' && ^._id == ${groupBy}._ref ${searchFilter} ${includedFieldsFilter} ${progressFilter} ${customFilter}`
const lessonsFilterWithRestrictions = await new FilterBuilder(lessonsFilter).buildFilter()
entityFieldsString = `
'id': railcontent_id,
'type': _type,
name,
'head_shot_picture_url': thumbnail_url.asset->url,
'web_url_path': '/${brand}/${webUrlPath}/'+name+'?included_fieds[]=type,${type}',
'all_lessons_count': count(*[${lessonsFilterWithRestrictions}]._id),
'children': *[${lessonsFilterWithRestrictions}]{
${fieldsString},
${groupBy}
}[0...20]
`
filter = `_type == '${groupBy}' && count(*[${lessonsFilterWithRestrictions}]._id) > 0`
} else if (groupBy !== '') {
const childrenFilter = await new FilterBuilder(``, { isChildrenFilter: true }).buildFilter()
const webUrlPath = groupBy == 'genre' ? '/genres' : ''
const lessonsFilter = `brand == '${brand}' && ^._id in ${groupBy}[]._ref ${typeFilter} ${searchFilter} ${includedFieldsFilter} ${progressFilter} ${customFilter}`
const lessonsFilterWithRestrictions = await new FilterBuilder(lessonsFilter).buildFilter()
entityFieldsString = `
'id': railcontent_id,
'type': _type,
name,
'head_shot_picture_url': thumbnail_url.asset->url,
'web_url_path': select(defined(web_url_path)=> web_url_path +'?included_fieds[]=type,${type}',!defined(web_url_path)=> '/${brand}${webUrlPath}/'+name+'/${webUrlPathType}'),
'all_lessons_count': count(*[${lessonsFilterWithRestrictions}]._id),
'children': *[${lessonsFilterWithRestrictions}]{
${fieldsString},
'lesson_count': coalesce(count(child[${childrenFilter}]->), 0) ,
${groupBy}
}[0...20]`
filter = `_type == '${groupBy}' && count(*[${lessonsFilterWithRestrictions}]._id) > 0`
} else {
filter = `brand == "${brand}" ${typeFilter} ${searchFilter} ${includedFieldsFilter} ${progressFilter} ${customFilter}`
const childrenFilter = await new FilterBuilder(``, { isChildrenFilter: true }).buildFilter()
entityFieldsString = ` ${fieldsString},
'lesson_count': coalesce(count(child[${childrenFilter}]->), 0) ,
'length_in_seconds': coalesce(
math::sum(
select(
child[${childrenFilter}]->length_in_seconds
)
),
length_in_seconds
),`
}
const filterWithRestrictions = await new FilterBuilder(filter, {
bypassStatuses: bypassStatusAndPublishedValidation,
bypassPermissions: bypassPermissions,
bypassPublishedDateRestriction: bypassStatusAndPublishedValidation,
}).buildFilter()
query = buildEntityAndTotalQuery(filterWithRestrictions, entityFieldsString, {
sortOrder: sortOrder,
start: start,
end: end,
})
return fetchSanity(query, true)
}
async function getProgressFilter(progress, progressIds) {
switch (progress) {
case 'all':
return progressIds !== undefined ? `&& railcontent_id in [${progressIds.join(',')}]` : ''
case 'in progress': {
const ids = await getAllStarted()
return `&& railcontent_id in [${ids.join(',')}]`
}
case 'completed': {
const ids = await getAllCompleted()
return `&& railcontent_id in [${ids.join(',')}]`
}
case 'not started': {
const ids = await getAllStartedOrCompleted()
return `&& !(railcontent_id in [${ids.join(',')}])`
}
case 'recent': {
const ids = progressIds !== undefined ? progressIds : await getAllStartedOrCompleted()
return `&& (railcontent_id in [${ids.join(',')}])`
}
case 'incomplete': {
const ids = progressIds !== undefined ? progressIds : await getAllStarted()
return `&& railcontent_id in [${ids.join(',')}]`
}
default:
throw new Error(`'${progress}' progress option not implemented`)
}
}
export function getSortOrder(sort = '-published_on', brand, groupBy) {
const sanitizedSort = sort?.trim() || '-published_on'
let isDesc = sanitizedSort.startsWith('-')
const sortField = isDesc ? sanitizedSort.substring(1) : sanitizedSort
let sortOrder = ''
switch (sortField) {
case 'slug':
if (groupBy) {
sortOrder = 'name'
} else {
sortOrder = '!defined(title_for_sort), title_for_sort'
sortOrder += isDesc ? ' desc' : ' asc'
sortOrder += ', !defined(title), lower(title)' // title fallback
}
break
case 'popularity':
if (groupBy == 'artist' || groupBy == 'genre') {
sortOrder = isDesc ? `coalesce(popularity.${brand}, -1)` : 'popularity'
} else {
sortOrder = isDesc ? 'coalesce(popularity, -1)' : 'popularity'
}
break
case 'recommended':
sortOrder = 'published_on'
isDesc = true
break
default:
sortOrder = sortField
break
}
sortOrder += isDesc ? ' desc' : ' asc'
return sortOrder
}
/**
* Fetches all available filter options based on brand, filters, and various optional criteria.
*
* This function constructs a query to retrieve the total number of results and filter options such as difficulty, instrument type, and genre.
* The filter options are dynamically generated based on the provided filters, style, artist, and content type.
* If a coachId is provided, the content type must be 'coach-lessons'.
*
* @param {string} brand - Brand to filter.
* @param {string[]} filters - Key-value pairs to filter the query.
* @param {string} [style] - Optional style/genre filter.
* @param {string} [artist] - Optional artist name filter.
* @param {string} contentType - Content type (e.g., 'song', 'lesson').
* @param {string} [term] - Optional search term for title, album, artist, or genre.
* @param {Array<string>} [progressIds] - Optional array of progress IDs to filter by.
* @param {string} [coachId] - Optional coach ID (only valid if contentType is 'coach-lessons').
* @param {boolean} [includeTabs=false] - Whether to include tabs in the returned metadata.
* @returns {Promise<Object>} - The filter options and metadata.
* @throws {Error} If coachId is provided but contentType isn't 'coach-lessons'.
*
* @example
* // Fetch filter options for 'song' content type:
* fetchAllFilterOptions('myBrand', [], 'Rock', 'John Doe', 'song', 'Love')
* .then(options => console.log(options))
* .catch(error => console.error(error));
*
* @example
* // Fetch filter options for a coach's lessons with coachId:
* fetchAllFilterOptions('myBrand', [], 'Rock', 'John Doe', 'coach-lessons', 'Love', undefined, '123')
* .then(options => console.log(options))
* .catch(error => console.error(error));
*/
export async function fetchAllFilterOptions(
brand,
filters = [],
style,
artist,
contentType,
term,
progressIds,
coachId,
includeTabs = false
) {
if (contentType == 'lessons' || contentType == 'songs') {
const metaData = processMetadata(brand, contentType, true)
return {
meta: metaData,
}
}
if (coachId && contentType !== 'coach-lessons') {
throw new Error(
`Invalid contentType: '${contentType}' for coachId. It must be 'coach-lessons'.`
)
}
const includedFieldsFilter = filters?.length ? filtersToGroq(filters) : undefined
const progressFilter = progressIds ? `&& railcontent_id in [${progressIds.join(',')}]` : ''
const adapter = getPermissionsAdapter()
const userPermissionsData = await adapter.fetchUserPermissions()
const isAdmin = adapter.isAdmin(userPermissionsData)
const constructCommonFilter = (excludeFilter) => {
const filterWithoutOption = excludeFilter
? filtersToGroq(filters, excludeFilter)
: includedFieldsFilter
const statusFilter = ' && status == "published"'
const includeStatusFilter = !isAdmin && !['instructor', 'artist', 'genre'].includes(contentType)
return coachId
? `brand == '${brand}' && status == "published" && references(*[_type=='instructor' && railcontent_id == ${coachId}]._id) ${filterWithoutOption || ''} ${term ? ` && (title match "${term}" || album match "${term}" || artist->name match "${term}" || genre[]->name match "${term}")` : ''}`
: `_type == '${contentType}' && brand == "${brand}"${includeStatusFilter ? statusFilter : ''}${style && excludeFilter !== 'style' ? ` && '${style}' in genre[]->name` : ''}${artist && excludeFilter !== 'artist' ? ` && artist->name == "${artist}"` : ''} ${progressFilter} ${filterWithoutOption || ''} ${term ? ` && (title match "${term}" || album match "${term}" || artist->name match "${term}" || genre[]->name match "${term}")` : ''}`
}
const metaData = processMetadata(brand, contentType, true)
const allowableFilters = metaData?.allowableFilters || []
const tabs = metaData?.tabs || []
const catalogName = metaData?.shortname || metaData?.name
const dynamicFilterOptions = allowableFilters
.map((filter) => getFilterOptions(filter, constructCommonFilter(filter), contentType, brand))
.join(' ')
const query = `
{
"meta": {
"totalResults": count(*[${constructCommonFilter()}
${term ? ` && (title match "${term}" || album match "${term}" || artist->name match "${term}" || genre[]->name match "${term}")` : ''}]),
"filterOptions": {
${dynamicFilterOptions}
}
}
}`
const results = await fetchSanity(query, true, { processNeedAccess: false })
return includeTabs ? { ...results, tabs, catalogName } : results
}
/**
* Fetch the next piece of content under a parent by Railcontent ID
* @param {int} railcontentId - The Railcontent ID of the parent content
* @returns {Promise<{next: (Object|null)}|null>} - object with 'next' attribute
* @example
* jumpToContinueContent(296693)
* then.(data => { console.log('next', data.next);})
* .catch(error => console.error(error));
*/
export async function jumpToContinueContent(railcontentId) {
const nextContent = await fetchNextContentDataForParent(railcontentId)
if (!nextContent || !nextContent.id) {
return null
}
let next = await fetchByRailContentId(nextContent.id, nextContent.type)
return { next }
}
/**
* Fetch the page data for a specific lesson by Railcontent ID.
* @param {string} railContentId - The Railcontent ID of the current lesson.
* @parent {boolean} addParent - Whether to include parent content data in the response.
* @returns {Promise<Object|null>} - The fetched page data or null if found.
*
* @example
* fetchLessonContent('lesson123')
* .then(data => console.log(data))
* .catch(error => console.error(error));
*/
export async function fetchLessonContent(railContentId, { addParent = false } = {}) {
const filterParams = {
isSingle: true,
pullFutureContent: true,
showMembershipRestrictedContent: true,
}
const parentQuery = addParent
? `"parent_content_data": *[railcontent_id in [...(^.parent_content_data[].id)]]{
"id": railcontent_id,
title,
slug,
"type": _type,
"logo" : logo_image_url.asset->url,
"dark_mode_logo": dark_mode_logo_url.asset->url,
"light_mode_logo": light_mode_logo_url.asset->url,
"badge": *[references(^._id) && _type == 'content-award'][0].badge.asset->url,
"badge_rear": *[references(^._id) && _type == 'content-award'][0].badge_rear.asset->url,
"badge_logo": *[references(^._id) && _type == 'content-award'][0].logo.asset->url,
'parentCount': coalesce(count(parent_content_data), 0)
} | order(parentCount desc),`
: ''
const fields = `${getFieldsForContentType()}
"resources": ${resourcesField},
soundslice,
instrumentless,
soundslice_slug,
"description": ${descriptionField},
"chapters": ${chapterField},
"instructors":instructor[]->name,
"instructor": ${instructorField},
${assignmentsField}
video,
"length_in_seconds": coalesce(soundslice[0].soundslice_length_in_second, length_in_seconds),
mp3_no_drums_no_click_url,
mp3_no_drums_yes_click_url,
mp3_yes_drums_no_click_url,
mp3_yes_drums_yes_click_url,
"permission_id": permission_v2,
${parentQuery}
...select(
defined(live_event_start_time) => {
live_event_start_time,
live_event_end_time,
live_event_stream_id,
"vimeo_live_event_id": vimeo_live_event_id,
"videoId": coalesce(live_event_stream_id, video.external_id),
"live_event_is_global": live_global_event == true
}
)
`
const query = await buildQuery(`railcontent_id == ${railContentId}`, filterParams, fields, {
isSingle: true,
})
const chapterProcess = (result) => {
const now = getSanityDate(new Date(), false)
if (result.live_event_start_time && result.live_event_end_time) {
result.isLive = result.live_event_start_time <= now && result.live_event_end_time >= now
}
const chapters = result.chapters ?? []
if (chapters.length === 0) return result
result.chapters = chapters.map((chapter, index) => ({
...chapter,
chapter_thumbnail_url: `https://musora-web-platform.s3.amazonaws.com/chapters/${result.brand}/Chapter${index + 1}.jpg`,
}))
return result
}
let contents = await fetchSanity(query, false, { customPostProcess: chapterProcess, processNeedAccess: true })
contents = postProcessBadge(contents)
return contents
}
/**
* Returns a list of recommended content based on the provided railContentId.
* If no recommendations found in recsys, falls back to fetching related lessons.
*
* @param railContentId
* @param brand
* @param count
* @returns {Promise<Array<Object>>}
*/
export async function fetchRelatedRecommendedContent(railContentId, brand, count = 10) {
const recommendedItems = await fetchSimilarItems(railContentId, brand, count)
if (recommendedItems && recommendedItems.length > 0) {
return fetchByRailContentIds(recommendedItems, 'tab-data', brand, true)
}
return await fetchRelatedLessons(railContentId, brand).then((result) =>
result.related_lessons?.splice(0, count)
)
}
/**
* Get song type (transcriptions, jam packs, play alongs, tutorial children) content documents that share content information with the provided railcontent document.
* These are linked through content that shares a license with the provided railcontent document
*
* @param railcontentId
* @param brand
* @param count
* @returns {Promise<Array<Object>>}
*/
export async function fetchOtherSongVersions(railcontentId, brand, count = 3) {
return fetchRelatedByLicense(railcontentId, brand, true, count)
}
/**
* Get non-song content documents that share content information with the provided railcontent document.
* These are linked through content that shares a license with the provided railcontent document
*
* @param {integer} railcontentId
* @param {string} brand
* @param {integer:3} count
* @returns {Promise<Array<Object>>}
*/
export async function fetchLessonsFeaturingThisContent(railcontentId, brand, count = 3) {
return fetchRelatedByLicense(railcontentId, brand, false, count)
}
/**
* Get content documents that share license information with the provided railcontent id
*
* @param {integer} railcontentId
* @param {string} brand
* @param {boolean} onlyUseSongTypes - if true, only return the song type documents. If false, return everything except those
* @param {integer:3} count
* @returns {Promise<Array<Object>>}
*/
async function fetchRelatedByLicense(railcontentId, brand, onlyUseSongTypes, count) {
const typeCheck = `@->_type in [${arrayJoinWithQuotes(SONG_TYPES)}]`
let typeCheckString = `@->brand == '${brand}' && `
typeCheckString += onlyUseSongTypes ? `${typeCheck}` : `!(${typeCheck})`
const contentFromLicenseFilter = `_type == 'license' && references(^._id)].content[${typeCheckString} && @->railcontent_id != ${railcontentId}`
let filterSongTypesWithSameLicense = await new FilterBuilder(contentFromLicenseFilter, {
isChildrenFilter: true,
}).buildFilter()
let queryFields = getFieldsForContentType()
const baseParentQuery = `railcontent_id == ${railcontentId}`
let parentQuery = await new FilterBuilder(baseParentQuery).buildFilter()
// queryFields = 'railcontent_id, title'
// parentQuery = baseParentQuery
// filterSongTypesWithSameLicense = contentFromLicenseFilter
const query = `*[${parentQuery}]{
_type, railcontent_id,
"related_by_license" :
*[${filterSongTypesWithSameLicense}]->{${queryFields}}|order(published_on desc, title asc)[0...${count}],
}[0...1]`
const results = await fetchSanity(query, false)
return results ? (results['related_by_license'] ?? []) : []
}
/**
* Fetch sibling lessons to a specific lesson
* @param {string} railContentId - The RailContent ID of the current lesson.
* @param {string} brand - The current brand.
* @returns {Promise<Array<Object>|null>} - The fetched related lessons data or null if not found.
*/
export async function fetchSiblingContent(railContentId, brand = null) {
const filterGetParent = await new FilterBuilder(`references(^._id) && _type == ^.parent_type`, {
pullFutureContent: true,
showMembershipRestrictedContent: true, // Show parent even without permissions
}).buildFilter()
const filterForParentList = await new FilterBuilder(
`references(^._id) && _type == ^.parent_type`,
{
pullFutureContent: true,
isParentFilter: true,
showMembershipRestrictedContent: true, // Show parent even without permissions
}
).buildFilter()
const childrenFilter = await new FilterBuilder(``, {
isChildrenFilter: true,
showMembershipRestrictedContent: true, // Show all lessons in sidebar, need_access applied on individual page
}).buildFilter()
const brandString = brand ? ` && brand == "${brand}"` : ''
const queryFields = getFieldsForContentType()
const query = `*[railcontent_id == ${railContentId}${brandString}]{
_type,
parent_type,
railcontent_id,
'parent_id': ${parentField}.id,
'grandparent_id':${grandParentField}.id,
'for-calculations': *[${filterGetParent}][0]{
'siblings-list': child[]->railcontent_id,
'parents-list': *[${filterForParentList}][0].child[]->railcontent_id
},
"related_lessons" : *[${filterGetParent}][0].child[${childrenFilter}]->{${queryFields}}
}`
let result = await fetchSanity(query, false, { processNeedAccess: true })
//there's no way in sanity to retrieve the index of an array, so we must calculate after fetch
if (result['for-calculations'] && result['for-calculations']['parents-list']) {
const calc = result['for-calculations']
const parentCount = calc['parents-list'].length
const currentParentIndex = calc['parents-list'].indexOf(result['parent_id']) + 1
const siblingCount = calc['siblings-list'].length
const currentSiblingIndex = calc['siblings-list'].indexOf(result['railcontent_id']) + 1
delete result['for-calculations']
if (result['grandparent_id']) {
result['collection_data'] = await fetchCourseCollectionData(result['grandparent_id'])
}
result = { ...result, parentCount, currentParentIndex, siblingCount, currentSiblingIndex }
return result
} else {
delete result['for-calculations']
return result
}
}
/**
* Fetch lessons related to a specific lesson by RailContent ID and type.
* @param {string} railContentId - The RailContent ID of the current lesson.
* @returns {Promise<Array<Object>|null>} - The fetched related lessons data or null if not found.
*/
export async function fetchRelatedLessons(railContentId) {
const defaultFilterFields = `_type==^._type && brand == ^.brand && railcontent_id != ${railContentId}`
const filterSameArtist = await new FilterBuilder(
`${defaultFilterFields} && references(^.artist->_id)`,
{ showMembershipRestrictedContent: true }
).buildFilter()
const filterSameGenre = await new FilterBuilder(
`${defaultFilterFields} && references(^.genre[]->_id)`,
{ showMembershipRestrictedContent: true }
).buildFilter()
const filterSameDifficulty = await new FilterBuilder(
`${defaultFilterFields} && difficulty == ^.difficulty`,
{ showMembershipRestrictedContent: true }
).buildFilter()
const queryFields = getFieldsForContentType()
const query = `*[railcontent_id == ${railContentId} && (!defined(permission) || references(*[_type=='permission']._id))]{
_type, parent_type, railcontent_id,
"related_lessons" : array::unique([
...(*[${filterSameArtist}]{${queryFields}}|order(published_on desc, title asc)[0...10]),
...(*[${filterSameGenre}]{${queryFields}}|order(published_on desc, title asc)[0...10]),
...(*[${filterSameDifficulty}]{${queryFields}}|order(published_on desc, title asc)[0...10]),
])[0...10]}`
return await fetchSanity(query, false, { processNeedAccess: true })
}
export async function fetchLiveEvent(brand, forcedContentId = null) {
const LIVE_EXTRA_MINUTES = 30
//calendarIDs taken from addevent.php
// TODO import instructor calendars to Sanity
let defaultCalendarID = ''
switch (brand) {
case 'drumeo':
defaultCalendarID = 'GP142387'
break
case 'pianote':
defaultCalendarID = 'be142408'
break
case 'guitareo':
defaultCalendarID = 'IJ142407'
break
case 'singeo':
defaultCalendarID = 'bk354284'
break
default:
break
}
let startDateTemp = new Date()
let endDateTemp = new Date()
startDateTemp = new Date(
startDateTemp.setMinutes(startDateTemp.getMinutes() + LIVE_EXTRA_MINUTES)
)
endDateTemp = new Date(endDateTemp.setMinutes(endDateTemp.getMinutes() - LIVE_EXTRA_MINUTES))
const liveEventFields = liveFields + `, 'event_coach_calendar_id': coalesce(calendar_id, '${defaultCalendarID}')`
const baseFilter =
forcedContentId !== null
? `railcontent_id == ${forcedContentId}`
: `status == 'scheduled'
&& (brand == '${brand}' || live_global_event == true)
&& defined(live_event_start_time)
&& live_event_start_time <= '${getSanityDate(startDateTemp, false)}'
&& live_event_end_time >= '${getSanityDate(endDateTemp, false)}'`
const filter = await new FilterBuilder(baseFilter, {bypassPermissions: true}).buildFilter()
// This query finds the first scheduled event (sorted by start_time) that ends after now()
const query = `*[${filter}]{${liveEventFields}} | order(live_event_start_time)[0...1]`
return await fetchSanity(query, false, { processNeedAccess: false })
}
/**
* Fetch the data needed for the CourseCollection Overview screen.
* @param {number} id - The Railcontent ID of the CourseCollection
* @returns {Promise<Object|null>} - The CourseCollection information and lessons or null if not found.
*
* @example
* fetchCourseCollectionData(404048)
* .then(CourseCollection => console.log(CourseCollection))
* .catch(error => console.error(error));
*/
export async function fetchCourseCollectionData(id) {
const builder = await new FilterBuilder(`railcontent_id == ${id}`).buildFilter()
const query = `*[${builder}]{
${await getFieldsForContentTypeWithFilteredChildren('course-collection')}
} [0...1]`
return fetchSanity(query, false)
}
/**
* DEPRECATED: Use fetchCourseCollectionData
* Fetch the data needed for the Pack Overview screen.
* @param {number} id - The Railcontent ID of the pack
* @returns {Promise<Object|null>} - The pack information and lessons or null if not found.
*
* @example
* fetchPackData(404048)
* .then(pack => console.log(pack))
* .catch(error => console.error(error));
*/
export async function fetchPackData(id) {
return fetchCourseCollectionData(id)
}
/**
* Fetch the data needed for the coach screen.
* @param {string} id - The Railcontent ID of the coach
*
* @returns {Promise<Object|null>} - The lessons for the instructor or null if not found.
*
* @example
* fetchCoachLessons('coach123')
* .then(lessons => console.log(lessons))
* .catch(error => console.error(error));
*/
export async function fetchByReference(
brand,
{ sortOrder = '-published_on', searchTerm = '', page = 1, limit = 20, includedFields = [] } = {}
) {
const fieldsString = getFieldsForContentType()
const start = (page - 1) * limit
const end = start + limit
const searchFilter = searchTerm ? `&& title match "${searchTerm}*"` : ''
const includedFieldsFilter = includedFields.length > 0 ? includedFields.join(' && ') : ''
const filter = `brand == '${brand}' ${searchFilter} && references(*[${includedFieldsFilter}]._id)`
const filterWithRestrictions = await new FilterBuilder(filter).buildFilter()
const query = buildEntityAndTotalQuery(filterWithRestrictions, fieldsString, {
sortOrder: getSortOrder(sortOrder, brand),
start: start,
end: end,
})
return fetchSanity(query, true)
}
/**
*
* Return the top level parent content railcontent_id.
* Ignores learning-path-v2 parents.
* ex: if railcontentId is of type 'skill-pack-lesson', return the corresponding 'skill-pack' railcontent_id
*
* @param {int} railcontentId
* @returns {Promise<int|null>}
*/
export async function fetchTopLevelParentId(railcontentId) {
const parentFilter = 'railcontent_id in [...(^.parent_content_data[].id)] && (!defined(parent_content_data) || count(parent_content_data) == 0)'
const statusFilter = "&& status in ['scheduled', 'published', 'archived', 'unlisted']"
const query = `*[railcontent_id == ${railcontentId}]{
railcontent_id,
'top_parent': *[${parentFilter} ${statusFilter}][0].railcontent_id
}`
let response = await fetchSanity(query, false, { processNeedAccess: false })
if (!response) return null
return response['top_parent'] ?? response['railcontent_id']
}
export async function getHierarchy(contentId, collection) {
let response
if (collection && collection.type === COLLECTION_TYPE.LEARNING_PATH) {
response = await fetchLearningPathHierarchyData(contentId, collection)
} else {
response = await fetchALaCarteHierarchyData(contentId)
}
if (!response) return null
const topLevelId = response.railcontent_id ?? response.id
if (!topLevelId) {
console.error('Top level ID not found in hierarchy response', response)
return null
}
let data = {
topLevelId: topLevelId,
parents: {},
children: {},
metadata: {},
}
populateHierarchyLookups(response, data, null)
data.metadata = extractMetadataFromHierarchy(response)
return data
}
function extractMetadataFromHierarchy(hierarchyData) {
let metadata = {}
function recursiveExtract(currentLevel, parentMetadata = {}) {
const railcontentIdField = currentLevel.railcontent_id ? 'railcontent_id' : 'id'
let contentId = currentLevel[railcontentIdField]
metadata[contentId] = {
type: currentLevel.metadata?.type ?? 'assignment',
brand: currentLevel.metadata?.brand ?? parentMetadata.brand,
parent_id: currentLevel.metadata?.parent_id ?? parentMetadata.parent_id,
}
let children = currentLevel['children']
if (children) {
for (let i = 0; i < children.length; i++) {
recursiveExtract(children[i], metadata[contentId])
}
}
let assignments = currentLevel['assignments']
if (assignments) {
for (let i = 0; i < assignments.length; i++) {
recursiveExtract(assignments[i], metadata[contentId])
}
}
}
recursiveExtract(hierarchyData)
return metadata
}
async function fetchLearningPathHierarchyData(railcontentId, collection) {
if (!collection) {
return null
}
const topLevelId = collection.id
return (await fetchByRailContentIds([topLevelId], 'hierarchy-data'))[0]
}
async function fetchALaCarteHierarchyData(railcontentId) {
let topLevelId = await fetchTopLevelParentId(railcontentId)
const childrenFilter = await new FilterBuilder(``, { isChildrenFilter: true }).buildFilter()
const query = `*[railcontent_id == ${topLevelId}]{
railcontent_id,
'metadata': { brand, 'type': _type, 'parent_id': coalesce(parent_content_data[0].id, 0) },
'assignments': assignment[]{railcontent_id},
'children': child[${childrenFilter}]->{
railcontent_id,
'metadata': {
brand, 'type': _type, 'parent_id': coalesce(parent_content_data[0].id, 0) },
'assignments': assignment[]{railcontent_id},
'children': child[${childrenFilter}]->{
railcontent_id,
'metadata': {
brand, 'type': _type, 'parent_id': coalesce(parent_content_data[0].id, 0) },
'assignments': assignment[]{railcontent_id},
'children': child[${childrenFilter}]->{
railcontent_id,
'metadata': {
brand, 'type': _type, 'parent_id': coalesce(parent_content_data[0].id, 0) },
'assignments': assignment[]{railcontent_id},
'children': child[${childrenFilter}]->{
railcontent_id,
'metadata': {
brand, 'type': _type, 'parent_id': coalesce(parent_content_data[0].id, 0) },
}
}
}
},
}`
return await fetchSanity(query, false, { processNeedAccess: false })
}
function populateHierarchyLookups(currentLevel, data, parentId) {
const railcontentIdField = currentLevel.railcontent_id ? 'railcontent_id' : 'id'
let contentId = currentLevel[railcontentIdField]
let children = currentLevel['children']
data.parents[contentId] = parentId
if (children) {
data.children[contentId] = children.map((child) => child[railcontentIdField])
for (let i = 0; i < children.length; i++) {
populateHierarchyLookups(children[i], data, contentId)
}
} else {
data.children[contentId] = []
}
let assignments = currentLevel['assignments']
if (assignments) {
let assignmentIds = assignments.map((assignment) => assignment[railcontentIdField]).filter(Boolean)
if (assignmentIds.length > 0) {
data.children[contentId] = (data.children[contentId] ?? []).concat(assignmentIds)
assignmentIds.forEach((assignmentId) => {
if (assignmentId) data.parents[assignmentId] = contentId
})
}
}
}
/**
* Fetch data for comment mod page
*
* @param {array} ids - List of ids get data for
* @returns {Promise<Object|null>} - A promise that resolves to an object containing the data
*/
export async function fetchCommentModContentData(ids) {
const idsString = ids.join(',')
const fields = `"id": railcontent_id, "type": _type, title, "url": web_url_path, "parent": *[^._id in child[]._ref]{"id": railcontent_id, title}`
const query = await buildQuery(
`railcontent_id in [${idsString}]`,
{ bypassPermissions: true },
fields,
{ end: 50 }
)
let data = await fetchSanity(query, true)
let mapped = {}
data.forEach(function (content) {
mapped[content.id] = {
id: content.id,
type: content.type,
title: content.title,
url: content.url,
parentTitle: content.parent[0]?.title ?? null,
}
})
return mapped
}
/**
*
* @param {string} query - The GROQ query to execute against the Sanity API.
* @param {boolean} isList - Whether to return an array or a single result.
* @param {Object} options - Additional options for fetching data.
* @param {Function} [options.customPostProcess=null] - custom post process callback
* @param {boolean} [options.processNeedAccess=true] - execute the needs_access callback
* @param {boolean} [options.processPageType=true] - execute the page_type callback
* @returns {Promise<*|null>} - A promise that resolves to the fetched data or null if an error occurs or no results are found.
*
* @example
* const query = `*[_type == "song"]{title, artist->name}`;
* fetchSanity(query, true)
* .then(data => console.log(data))
* .catch(error => console.error(error));
*/
export async function fetchSanity(
query,
isList,
{ customPostProcess = null, processNeedAccess = true, processPageType = true } = {}
) {
// Check the config object before proceeding
if (!checkSanityConfig(globalConfig)) {
return null
}
const perspective = globalConfig.sanityConfig.perspective ?? 'published'
const api = globalConfig.sanityConfig.useCachedAPI ? 'apicdn' : 'api'
const baseUrl = `https://${globalConfig.sanityConfig.projectId}.${api}.sanity.io/v${globalConfig.sanityConfig.version}/data/query/${globalConfig.sanityConfig.dataset}?perspective=${perspective}`
try {
const encodedQuery = encodeURIComponent(query)
const fullGetUrl = `${baseUrl}&query=${encodedQuery}`
const useGet = fullGetUrl.length < 8000
let url, method, options
if (useGet) {
url = fullGetUrl
method = 'GET'
options = {
method,
}
} else {
url = baseUrl
method = 'POST'
options = {
method,
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ query }),
}
}
const adapter = getPermissionsAdapter()
let promisesResult = await Promise.all([
fetch(url, options),
processNeedAccess ? adapter.fetchUserPermissions() : null,
])
const response = promisesResult[0]
const userPermissions = promisesResult[1]
if (!response.ok) {
throw new Error(`Sanity API error: ${response.status} - ${response.statusText}`)
}
const result = await response.json()
if (result.result) {
let results = isList ? result.result : result.result[0]
if (!results) {
return null
}
results = processNeedAccess ? await needsAccessDecorator(results, userPermissions) : results
results = processPageType ? pageTypeDecorator(results) : results
return customPostProcess ? customPostProcess(results) : results
} else {
return null
}
} catch (error) {
console.error('fetchSanity: Fetch error:', { error, query })
return null
}
}
function contentResultsDecorator(results, fieldName, callback) {
const processChildren = (result, depth = 0) => {
if (result.children && Array.isArray(result.children)) {
result.children.forEach((child) => {
if (child && depth < 3) { // course-collections are only 3 depth
child[fieldName] = callback(child)
processChildren(child, depth + 1)
}
})
}
}
if (Array.isArray(results)) {
results.forEach((result) => {
// Check if this is a content row structure
if (result.content && Array.isArray(result.content)) {
// Content rows structure: array of rows, each with a content array
result.content.forEach((contentItem) => {
if (contentItem) {
contentItem[fieldName] = callback(contentItem)
}
processChildren(contentItem)
})
} else {
result[fieldName] = callback(result)
processChildren(result)
}
})
} else if (results.entity && Array.isArray(results.entity)) {
// Group By
results.entity.forEach((result) => {
if (result.lessons) {
result.lessons.forEach((lesson) => {
lesson[fieldName] = callback(lesson) // Updated to check lesson access
processChildren(lesson)
})
} else {
result[fieldName] = callback(result)
processChildren(result)
}
})
} else if (results.related_lessons && Array.isArray(results.related_lessons)) {
results.related_lessons.forEach((result) => {
result[fieldName] = callback(result)
processChildren(result)
})
} else if (results.data && Array.isArray(results.data)) {
results.data.forEach((result) => {
result[fieldName] = callback(result)
processChildren(result)
})
} else if (results.lessons && results.livestreams && results.songs) {
// `fetchScheduledAndNewReleases` response structure
['lessons', 'livestreams', 'songs'].forEach((key) => {
if (results[key] && Array.isArray(results[key])) {
results[key].forEach((item) => {
item[fieldName] = callback(item)
processChildren(item)
})
}
})
} else {
results[fieldName] = callback(results)
processChildren(results) // this on was always true
}
return results
}
function pageTypeDecorator(results) {
return contentResultsDecorator(results, 'page_type', function (content) {
return SONG_TYPES_WITH_CHILDREN.includes(content['type']) ? 'song' : 'lesson'
})
}
function needsAccessDecorator(results, userPermissions) {
if (globalConfig.sanityConfig.useDummyRailContentMethods) return results
const adapter = getPermissionsAdapter()
return contentResultsDecorator(results, 'need_access', function (content) {
return adapter.doesUserNeedAccess(content, userPermissions)
})
}
function doesUserNeedAccessToContent(result, userPermissions) {
// Legacy function - now delegates to adapter
// Kept for backwards compatibility if used elsewhere
const adapter = getPermissionsAdapter()
return adapter.doesUserNeedAccess(result, userPermissions)
}
/**
* Fetch shows data for a brand.
*
* @param brand - The brand for which to fetch shows.
* @returns {Promise<{name, description, type: *, thumbnailUrl}>}
*
* @example
*
* fetchShowsData('drumeo')
* .then(data => console.log(data))
* .catch(error => console.error(error));
*/
export async function fetchShowsData(brand) {
let shows = showsTypes[brand] ?? []
const showsInfo = []
shows.forEach((type) => {
const processedData = processMetadata(brand, type)
if (processedData) showsInfo.push(processedData)
})
return showsInfo
}
/**
* Fetch metadata from the contentMetaData.js based on brand and type.
* For v2 you need to provide page type('lessons' or 'songs') in type parameter
*
* @param {string} brand - The brand for which to fetch metadata.
* @param {string} type - The type for which to fetch metadata.
* @param {Object|boolean} [options={}] - Options object or legacy boolean for withFilters
* @param {boolean} [options.skipTabFiltering=false] - Skip dynamic tab filtering (internal use)
* @returns {Promise<{name, description, type: *, thumbnailUrl}>}
*
* @example
* // Standard usage (with tab filtering)
* fetchMetadata('drumeo', 'lessons')
*
* @example
* // Internal usage (skip tab filtering to prevent recursion)
* fetchMetadata('drumeo', 'lessons', { skipTabFiltering: true })
*/
export async function fetchMetadata(brand, type, options = {}) {
// Handle backward compatibility - type was previously the 3rd param (boolean)
const withFilters = typeof options === 'boolean' ? options : true
const skipTabFiltering = options.skipTabFiltering || false
let processedData = processMetadata(brand, type, withFilters)
if (processedData?.onlyAvailableTabs === true) {
const activeTabs = await fetchRecentActivitiesActiveTabs()
processedData.tabs = activeTabs
}
if ((type === 'lessons' || type === 'songs') && !skipTabFiltering) {
try {
// Single API call to get all content type counts
const contentTypeCounts = await fetchContentTypeCounts(brand, type)
// Filter tabs based on counts
processedData.tabs = filterTabsByContentCounts(processedData.tabs, contentTypeCounts)
// Filter Type options based on counts
if (processedData.filters) {
processedData.filters = filterTypeOptionsByContentCounts(
processedData.filters,
contentTypeCounts
)
}
} catch (error) {
console.error('Error fetching content type counts, using all tabs/filters:', error)
// Fail open - show all tabs and filters
}
}
return processedData ? processedData : {}
}
export async function fetchChatAndLiveEnvent(brand, forcedId = null) {
const liveEvent =
forcedId !== null ? await fetchByRailContentIds([forcedId]) : [await fetchLiveEvent(brand)]
if (liveEvent.length === 0 || (liveEvent.length === 1 && !liveEvent[0])) {
return null
}
let url = `/content/live-chat?brand=${brand}`
const chatData = await GET(url)
return { ...chatData, ...liveEvent[0] }
}
//Helper Functions
function arrayJoinWithQuotes(array, delimiter = ',') {
const wrapped = array.map((value) => `'${value}'`)
return wrapped.join(delimiter)
}
export function getSanityDate(date, roundToHourForCaching = true) {
if (roundToHourForCaching) {
// We need to set the published on filter date to be a round time so that it doesn't bypass the query cache
// with every request by changing the filter date every second. I've set it to one minute past the current hour
// because publishing usually publishes content on the hour exactly which means it should still skip the cache
// when the new content is available.
// Round to the start of the current hour
const roundedDate = new Date(
date.getFullYear(),
date.getMonth(),
date.getDate(),
date.getHours()
)
return roundedDate.toISOString()
}
return date.toISOString()
}
function getDateOnly(date = new Date()) {
return date.toISOString().split('T')[0]
}
const merge = (a, b, predicate = (a, b) => a === b) => {
const c = [...a] // copy to avoid side effects
// add all items from B to copy C if they're not already present
b.forEach((bItem) => (c.some((cItem) => predicate(bItem, cItem)) ? null : c.push(bItem)))
return c
}
function checkSanityConfig(config) {
if (!config.sanityConfig.token) {
console.warn('fetchSanity: The "token" property is missing in the config object.')
return false
}
if (!config.sanityConfig.projectId) {
console.warn('fetchSanity: The "projectId" property is missing in the config object.')
return false
}
if (!config.sanityConfig.dataset) {
console.warn('fetchSanity: The "dataset" property is missing in the config object.')
return false
}
if (!config.sanityConfig.version) {
console.warn('fetchSanity: The "version" property is missing in the config object.')
return false
}
return true
}
function buildRawQuery(
filter = '',
fields = '...',
{ sortOrder = 'published_on desc', start = 0, end = 10, isSingle = false }
) {
const sortString = sortOrder ? `order(${sortOrder})` : ''
const countString = isSingle ? '[0...1]' : `[${start}...${end}]`
const query = `*[${filter}]{
${fields}
} | ${sortString}${countString}`
return query
}
async function buildQuery(
baseFilter = '',
filterParams = { pullFutureContent: false },
fields = '...',
{ sortOrder = 'published_on desc', start = 0, end = 10, isSingle = false }
) {
const filter = await new FilterBuilder(baseFilter, filterParams).buildFilter()
return buildRawQuery(filter, fields, { sortOrder, start, end, isSingle })
}
export function buildEntityAndTotalQuery(
filter = '',
fields = '...',
{
sortOrder = 'published_on desc',
start = 0,
end = 10,
isSingle = false,
withoutPagination = false,
}
) {
const sortString = sortOrder ? ` | order(${sortOrder})` : ''
const countString = isSingle ? '[0...1]' : withoutPagination ? `` : `[${start}...${end}]`
const query = `{
"entity": *[${filter}] ${sortString}${countString}
{
${fields}
},
"total": 0
}`
return query
}
function getFilterOptions(option, commonFilter, contentType, brand) {
let filterGroq = ''
const types = Array.from(new Set([...coachLessonsTypes, ...showsTypes[brand]]))
switch (option) {
case 'difficulty':
filterGroq = `
"difficulty": [
{"type": "All", "count": count(*[${commonFilter} && difficulty_string == "All"])},
{"type": "Introductory", "count": count(*[${commonFilter} && (difficulty_string == "Novice" || difficulty_string == "Introductory")])},
{"type": "Beginner", "count": count(*[${commonFilter} && difficulty_string == "Beginner"])},
{"type": "Intermediate", "count": count(*[${commonFilter} && difficulty_string == "Intermediate" ])},
{"type": "Advanced", "count": count(*[${commonFilter} && difficulty_string == "Advanced" ])},
{"type": "Expert", "count": count(*[${commonFilter} && difficulty_string == "Expert" ])}
][count > 0],`
break
case 'type':
const typesString = types
.map((t) => {
return `{"type": "${t}"}`
})
.join(', ')
filterGroq = `"type": [${typesString}]{type, 'count': count(*[_type == ^.type && ${commonFilter}])}[count > 0],`
break
case 'genre':
case 'essential':
case 'focus':
case 'theory':
case 'topic':
case 'lifestyle':
case 'creativity':
filterGroq = `
"${option}": *[_type == '${option}' ${contentType ? ` && '${contentType}' in filter_types` : ''} ] {
"type": name,
"count": count(*[${commonFilter} && references(^._id)])
}[count > 0],`
break
case 'instrumentless':
filterGroq = `
"${option}": [
{"type": "Full Song Only", "count": count(*[${commonFilter} && instrumentless == false ])},
{"type": "Instrument Removed", "count": count(*[${commonFilter} && instrumentless == true ])}
][count > 0],`
break
case 'gear':
filterGroq = `
"${option}": [
{"type": "Practice Pad", "count": count(*[${commonFilter} && gear match 'Practice Pad' ])},
{"type": "Drum-Set", "count": count(*[${commonFilter} && gear match 'Drum-Set'])}
][count > 0],`
break
case 'bpm':
filterGroq = `
"${option}": [
{"type": "50-90", "count": count(*[${commonFilter} && bpm > 50 && bpm < 91])},
{"type": "91-120", "count": count(*[${commonFilter} && bpm > 90 && bpm < 121])},
{"type": "121-150", "count": count(*[${commonFilter} && bpm > 120 && bpm < 151])},
{"type": "151-180", "count": count(*[${commonFilter} && bpm > 150 && bpm < 181])},
{"type": "180+", "count": count(*[${commonFilter} && bpm > 180])},
][count > 0],`
break
default:
filterGroq = ''
break
}
return filterGroq
}
function cleanUpGroq(query) {
// Split the query into clauses based on the logical operators
const clauses = query.split(/(\s*&&|\s*\|\|)/).map((clause) => clause.trim())
// Filter out empty clauses
const filteredClauses = clauses.filter((clause) => clause.length > 0)
// Check if there are valid conditions in the clauses
const hasConditions = filteredClauses.some((clause) => !clause.match(/^\s*&&\s*|\s*\|\|\s*$/))
if (!hasConditions) {
// If no valid conditions, return an empty string or the original query
return ''
}
// Remove occurrences of '&& ()'
const cleanedQuery = filteredClauses
.join(' ')
.replace(/&&\s*\(\)/g, '')
.replace(/(\s*&&|\s*\|\|)(?=\s*[\s()]*$|(?=\s*&&|\s*\|\|))/g, '')
.trim()
return cleanedQuery
}
// V2 methods
export async function fetchTabData(
brand,
pageName,
{
page = 1,
limit = 10,
sort = '-published_on',
includedFields = [],
progressIds = undefined,
progress = 'all',
showMembershipRestrictedContent = false,
excludeIds = [],
} = {}
) {
const start = (page - 1) * limit
const end = start + limit
// Construct the included fields filter, replacing 'difficulty' with 'difficulty_string'
const includedFieldsFilter =
includedFields.length > 0 ? filtersToGroq(includedFields, [], pageName) : ''
let sortOrder = getSortOrder(sort, brand, '')
switch (progress) {
case 'recent':
const metadata = { brand }
progressIds = await getAllStartedOrCompleted({ metadata })
sortOrder = null
break
case 'incomplete':
progressIds = await getAllStarted()
sortOrder = null
break
case 'completed':
progressIds = await getAllCompleted()
sortOrder = null
break
}
// limits the results to supplied progressIds for started & completed filters
const progressFilter = await getProgressFilter(progress, progressIds)
const fieldsString = getFieldsForContentType('tab-data')
const now = getSanityDate(new Date())
// Determine the group by clause
let query = ''
let entityFieldsString = ''
let filter = ''
const excludedIdsFilter = excludeIds.length
? `&& !(railcontent_id in [${excludeIds.join(',')}])`
: ''
const excludeCoursesInCourseCollectionsFilter = `&& !(_type == 'course' && defined(parent_content_data))`
filter = `brand == "${brand}" && (defined(railcontent_id)) ${includedFieldsFilter} ${progressFilter} ${excludedIdsFilter} ${excludeCoursesInCourseCollectionsFilter}`
const childrenFilter = await new FilterBuilder(``, {
isChildrenFilter: true,
showMembershipRestrictedContent: true,
}).buildFilter()
const childrenFields = await getChildFieldsForContentType('tab-data')
const lessonCountFilter = await new FilterBuilder(`_id in ^.child[]._ref`).buildFilter()
entityFieldsString = ` ${fieldsString}
'children': child[${childrenFilter}]->{ ${childrenFields} 'children': child[${childrenFilter}]->{ ${childrenFields} }, },
'isLive': live_event_start_time <= "${now}" && live_event_end_time >= "${now}",
'lesson_count': coalesce(count(*[${lessonCountFilter}]), 0),
'length_in_seconds': coalesce(
math::sum(
select(
child[${childrenFilter}]->length_in_seconds
)
),
length_in_seconds
),`
// Check if user is admin to determine available content statuses
const adapter = getPermissionsAdapter()
const userData = await adapter.fetchUserPermissions()
const isAdminORModerator = adapter.isAdmin(userData) || adapter.isModerator(userData)
const filterWithRestrictions = await new FilterBuilder(filter, {
showMembershipRestrictedContent: true,
availableContentStatuses: isAdminORModerator
? CONTENT_STATUSES.ADMIN_ALL
: CONTENT_STATUSES.PUBLISHED_ONLY,
pullFutureContent: isAdminORModerator ? true : false,
}).buildFilter()
query = buildEntityAndTotalQuery(filterWithRestrictions, entityFieldsString, {
sortOrder: sortOrder,
start: start,
end: progressIds ? progressIds.length + start : end, // sanity doesnt order progress correctly, so must return all and sort client side
})
let results = await fetchSanity(query, true, { processNeedAccess: true })
if (['recent', 'incomplete', 'completed'].includes(progress) && results.entity.length > 1) {
const orderMap = new Map(progressIds.map((id, index) => [id, index]))
results.entity = results.entity
.sort((a, b) => {
const aIdx = orderMap.get(a.id) ?? Number.MAX_SAFE_INTEGER
const bIdx = orderMap.get(b.id) ?? Number.MAX_SAFE_INTEGER
return aIdx - bIdx || new Date(b.published_on) - new Date(a.published_on)
})
.slice(start, end)
}
return results
}
export async function fetchRecent(
brand,
pageName,
{ page = 1, limit = 10, sort = '-published_on', includedFields = [], progress = 'recent' } = {}
) {
const mergedIncludedFields = [...includedFields, `tab,all`]
const results = await fetchTabData(brand, pageName, {
page,
limit,
sort,
includedFields: mergedIncludedFields,
progress: progress.toLowerCase(),
})
return results.entity
}
export async function fetchScheduledAndNewReleases(
brand,
// page param deprecated, doesnt have 1-1 affect on this functionality
// if we want to allow pagination, this requires a revisit
{ limit = 10 } = {}
) {
const maxLessons = 3
const maxSongs = 5
const maxLivestreams = 2
const rawNow = new Date()
const now = getSanityDate(rawNow)
const fifteenDaysAgo = getSanityDate(new Date(rawNow - 15 * 24 * 60 * 60 * 1000))
const parentsWithoutSong = parentRecentTypes.filter(type => type !== 'song')
const fields = await getFieldsForContentTypeWithFilteredChildren('new-and-scheduled')
const lessonFilter = f.combine(
"show_in_new_feed == true",
f.brand(brand),
f.typeIn(parentsWithoutSong),
f.statusIn(['published']),
f.publishedBefore(now),
f.publishedAfter(fifteenDaysAgo),
)
const songFilter = f.combine(
"show_in_new_feed == true",
f.brand(brand),
f.type('song'),
f.statusIn(['published']),
f.publishedBefore(now),
f.publishedAfter(fifteenDaysAgo),
)
const livestreamFilter = f.combine(
"show_in_new_feed == true",
f.combineOr(
f.brand(brand),
'live_global_event == true'
),
f.statusIn(['scheduled']),
`live_event_start_time >= '${now}'`,
)
const lessonQuery = query()
.and(lessonFilter)
.order('published_on desc')
.slice(0, maxLessons)
.select(fields)
.build()
const songQuery = query()
.and(songFilter)
.order('published_on desc')
.slice(0, maxSongs)
.select(fields)
.build()
const livestreamQuery = query()
.and(livestreamFilter)
.order('live_event_start_time asc')
.slice(0, maxLivestreams)
.select(fields)
.build()
const q = `{
"lessons": ${lessonQuery},
"songs": ${songQuery},
"livestreams": ${livestreamQuery}
}`
const r = await fetchSanity(q, true)
if (!r) {
return []
}
return reorderScheduledAndNewReleases(r, limit)
}
function reorderScheduledAndNewReleases(r, limit) {
let lessonLimit, songLimit, livestreamLimit
// discrete limit/order behaviour for this row
if (limit >= 10) {
lessonLimit = 3
songLimit = 5
livestreamLimit = 2
} else if (limit >= 3) {
lessonLimit = 2
livestreamLimit = 1
songLimit = limit - lessonLimit - livestreamLimit
} else {
lessonLimit = (limit > 0) ? 1 : 0
livestreamLimit = 0
songLimit = limit - lessonLimit
}
const lessons = r.lessons.slice(0, lessonLimit)
if (lessons.length < lessonLimit) {
songLimit += (lessonLimit - lessons.length)
}
const livestreams = r.livestreams.slice(0, livestreamLimit)
if (livestreams.length < livestreamLimit) {
songLimit += (livestreamLimit - livestreams.length)
}
const songs = r.songs.slice(0, songLimit)
return [...lessons, ...songs, ...livestreams]
}
export async function fetchShows(brand, type, sort = 'sort') {
const sortOrder = getSortOrder(sort, brand)
const filter = `_type == '${type}' && brand == '${brand}'`
const filterParams = {}
const query = await buildQuery(filter, filterParams, getFieldsForContentType(type), {
sortOrder: sortOrder,
end: 100, // Adrian: added for homepage progress rows, this should be handled gracefully
})
return fetchSanity(query, true)
}
/**
* Fetch the method intro video for a given brand.
* @param brand
* @returns {Promise<*|null>}
*/
export async function fetchMethodV2IntroVideo(brand) {
const type = 'method-intro'
const filter = `_type == '${type}' && brand == '${brand}'`
const fields = getIntroVideoFields('method-v2')
const query = `*[${filter}] { ${fields.join(', ')} }`
return fetchSanity(query, false)
}
/**
* Fetch the structure (just ids) of the Method for a given brand.
* @param brand
* @returns {Promise<*|null>}
*/
export async function fetchMethodV2Structure(brand) {
const _type = 'method-v2'
const query = `*[_type == '${_type}' && brand == '${brand}'][0...1]{
'sanity_id': _id,
brand,
'intro_video_id': intro_video->railcontent_id,
'learning_paths': child[@->status == 'published']->{
'id': railcontent_id,
status,
published_on,
'intro_video_id': intro_video->railcontent_id,
'children': child[]->railcontent_id
}
}`
return await fetchSanity(query, false)
}
/**
* Fetch the structure (just ids) of the Method of a given learning path or learning path lesson.
* @param contentId
* @returns {Promise<*|null>}
*/
export async function fetchMethodV2StructureFromId(contentId) {
const _type = 'method-v2'
const query = `*[_type == '${_type}' && brand == *[railcontent_id == ${contentId}][0].brand][0...1]{
'sanity_id': _id,
brand,
'intro_video_id': intro_video->railcontent_id,
'learning_paths': child[]->{
'id': railcontent_id,
'intro_video_id': intro_video->railcontent_id,
'children': child[]->railcontent_id
}
}`
return await fetchSanity(query, false)
}
/**
* Fetch content owned by the user (excluding membership content).
* Shows only content accessible through purchases/entitlements, not through membership.
*
* @param {string} brand - The brand to filter content by
* @param {Object} options - Fetch options
* @param {Array<string>} options.type - Content type(s) to filter (optional array, default: [])
* @param {number} options.page - Page number (default: 1)
* @param {number} options.limit - Items per page (default: 10)
* @param {string} options.sort - Sort field and direction (default: '-published_on')
* @returns {Promise<Object>} Object with 'entity' (content array) and 'total' (count)
*/
export async function fetchOwnedContent(
brand,
{ type = [], page = 1, limit = 10, sort = '-published_on' } = {}
) {
const start = (page - 1) * limit
const end = start + limit
// Determine the sort order
const sortOrder = getSortOrder(sort, brand)
// Build the type filter
let typeFilter = ''
if (type.length > 0) {
const typesString = type.map((t) => `'${t}'`).join(', ')
typeFilter = `&& _type in [${typesString}]`
}
// Build the base filter
const filter = `brand == "${brand}" ${typeFilter}`
// Apply owned content filter
const filterWithRestrictions = await new FilterBuilder(filter, {
showOnlyOwnedContent: true, // Key parameter: exclude membership content
}).buildFilter()
// Use 'tab-data' to include children field (needed for navigateTo calculation)
const fieldsString = await getFieldsForContentTypeWithFilteredChildren('tab-data', true)
const query = buildEntityAndTotalQuery(filterWithRestrictions, fieldsString, {
sortOrder: sortOrder,
start: start,
end: end,
})
return fetchSanity(query, true)
}
/**
* Fetch brands for given content IDs.
*
* @param {Array<number>} contentIds - Array of railcontent IDs
* @returns {Promise<Object>} - A promise that resolves to an object mapping content IDs to brands
*/
export async function fetchBrandsByContentIds(contentIds) {
if (!contentIds || contentIds.length === 0) {
return {}
}
const idsString = contentIds.join(',')
const query = `*[railcontent_id in [${idsString}]]{
railcontent_id,
brand
}`
const results = await fetchSanity(query, true)
const brandMap = {}
results.forEach((item) => {
brandMap[item.railcontent_id] = item.brand
})
return brandMap
}
/**
* Get all possible content types for a page type (lessons or songs).
* Returns unique array of Sanity content type strings.
* Uses the existing filterTypes mapping from contentTypeConfig.
*
* @param {string} pageName - Page name ('lessons' or 'songs')
* @returns {string[]} - Array of content type strings
*
* @example
* getAllContentTypesForPage('lessons')
* // Returns: ['lesson', 'quick-tips', 'course', 'guided-course', ...]
*/
function getAllContentTypesForPage(pageName) {
return filterTypes[pageName] || []
}
/**
* Fetch counts for all content types on a page (lessons/songs) in a single query.
* Uses GROQ aggregation to efficiently get counts for multiple content types.
* Only returns types with count > 0.
*
* @param {string} brand - Brand identifier (e.g., 'drumeo', 'playbass')
* @param {string} pageName - Page name ('lessons' or 'songs')
* @returns {Promise<Object.<string, number>>} - Object mapping content types to counts
*
* @example
* await fetchContentTypeCounts('playbass', 'lessons')
* // Returns: { 'guided-course': 45, 'skill-pack': 12, 'special': 8 }
*/
export async function fetchContentTypeCounts(brand, pageName) {
const allContentTypes = getAllContentTypesForPage(pageName)
if (allContentTypes.length === 0) {
return {}
}
// Build array of type objects for GROQ query
const typesString = allContentTypes.map((type) => `{"type": "${type}"}`).join(', ')
const query = `{
"typeCounts": [${typesString}]{
type,
'count': count(*[
_type == ^.type
&& brand == "${brand}"
&& status == "published"
])
}[count > 0]
}`
const results = await fetchSanity(query, true, { processNeedAccess: false })
// Convert array to object for easier lookup: { 'guided-course': 45, ... }
const countsMap = {}
if (results.typeCounts) {
results.typeCounts.forEach((item) => {
countsMap[item.type] = item.count
})
}
return countsMap
}
/**
* Filter tabs based on which content types have content.
* Always keeps 'For You' and 'Explore All' tabs.
*
* @param {Array} tabs - Array of tab objects from metadata
* @param {Object.<string, number>} contentTypeCounts - Content type counts
* @returns {Array} - Filtered array of tabs with content
*/
function filterTabsByContentCounts(tabs, contentTypeCounts) {
return tabs.filter((tab) => {
if (ALWAYS_VISIBLE_TABS.some((visibleTab) => visibleTab.name === tab.name)) {
return true
}
const tabContentTypes = TAB_TO_CONTENT_TYPES[tab.name] || []
if (tabContentTypes.length === 0) {
// Unknown tab - show it to be safe
console.warn(`Unknown tab "${tab.name}" - showing by default`)
return true
}
// Tab has content if ANY of its content types have count > 0
return tabContentTypes.some((type) => contentTypeCounts[type] > 0)
})
}
/**
* Filter Type filter options based on content type counts.
* Removes parent/child options that have no content available.
* Returns a new filters array (does not mutate original).
*
* @param {Array} filters - Filter groups array from metadata
* @param {Object.<string, number>} contentTypeCounts - Content type counts
* @returns {Array} - Filtered filter groups
*/
function filterTypeOptionsByContentCounts(filters, contentTypeCounts) {
return filters
.map((filter) => {
// Only process Type filter
if (filter.key !== 'type') {
return filter
}
const filteredItems = filter.items
.map((item) => {
// For hierarchical filters (parent with children)
if (item.isParent && item.items) {
// Filter children based on their content types
const availableChildren = item.items.filter((child) => {
const childTypes = getContentTypesForFilterName(child.name)
if (!childTypes || childTypes.length === 0) {
console.warn(`Unknown filter child "${child.name}" - showing by default`)
return true
}
// Child has content if ANY of its types have count > 0
return childTypes.some((type) => contentTypeCounts[type] > 0)
})
// Keep parent only if it has available children
if (availableChildren.length > 0) {
// Return NEW object to avoid mutation
return { ...item, items: availableChildren }
}
return null
}
// For flat items (no children)
const itemTypes = getContentTypesForFilterName(item.name)
if (!itemTypes || itemTypes.length === 0) {
console.warn(`Unknown filter item "${item.name}" - showing by default`)
return item
}
// Item has content if ANY of its types have count > 0
const hasContent = itemTypes.some((type) => contentTypeCounts[type] > 0)
return hasContent ? item : null
})
.filter(Boolean) // Remove nulls
// Return new filter object with filtered items
return {
...filter,
items: filteredItems,
}
})
.filter((filter) => {
if (filter.key === 'type' && filter.items.length === 0) {
return false
}
return true
})
}
/**
* Maps a display name to its corresponding content types from lessonTypesMapping.
* @param {string} displayName - The display name from filter metadata
* @returns {string[]|undefined} - Array of content types or undefined if not found
*/
function getContentTypesForFilterName(displayName) {
const displayNameToKey = {
Lessons: 'lessons',
'Practice Alongs': 'practice alongs',
'Live Archives': 'live archives',
'Student Archives': 'student archives',
Courses: 'courses',
'Guided Courses': 'guided courses',
'Course Collections': 'course collections',
Specials: 'specials',
Documentaries: 'documentaries',
Shows: 'shows',
'Skill Packs': 'skill packs',
Tutorials: 'tutorials',
Transcriptions: 'transcriptions',
'Sheet Music': 'sheet music',
Tabs: 'tabs',
'Play-Alongs': 'play-alongs',
'Jam Tracks': 'jam tracks',
}
const mappingKey = displayNameToKey[displayName]
return mappingKey ? lessonTypesMapping[mappingKey] : undefined
}
// this is so we can export the inner function from mcs
export function getSongTypesFor(brand) {
return getSongType(brand)
}
export function fetchParentChildRelationshipsFor(childIds, parentType) {
const stringIds = childIds.join(',')
const query = `*[_type == '${parentType}' && count(@.child[@->railcontent_id in [${stringIds}]]) > 0]{
railcontent_id,
"children": child[@->railcontent_id in [${stringIds}]]->railcontent_id
}`
return fetchSanity(query, true, { processNeedAccess: false, processPageType: false })
}
/**
* Checks whether the user has completed a Method V2 intro video on **any** brand.
*
* Fetches all `method-intro` content IDs from Sanity (cross-brand) and checks
* the local progress store for any completed record among them. This intentionally
* ignores the current brand so that completing the intro on one brand (e.g. PlayBass)
* is recognised as completed when the user switches to another brand (e.g. Drumeo).
*
* @returns {Promise<boolean>} `true` if the user has completed at least one Method V2
* intro video across any brand, `false` otherwise.
*/
export async function hasAnyMethodV2IntroCompleted() {
const type = 'method-intro'
const filter = `_type == '${type}'`
const query = `*[${filter}] { railcontent_id }`
const videos = await fetchSanity(query, true);
const ids = (videos || []).map((v) => v.railcontent_id)
const completedVideos = await getAllCompletedByIds(ids)
return (completedVideos?.data?.length || 0) > 0
}