/**
* @module Content-Services-V2
*/
import {
fetchByRailContentIds,
fetchMetadata,
fetchRecent,
fetchTabData,
fetchNewReleases,
fetchUpcomingEvents,
fetchScheduledReleases,
fetchReturning,
fetchLeaving, fetchScheduledAndNewReleases, fetchContentRows, fetchOwnedContent, fetchCourseCollectionData
} from './sanity.js'
import {TabResponseType, Tabs, capitalizeFirstLetter} from '../contentMetaData.js'
import {recommendations, rankCategories, rankItems} from "./recommendations";
import {addContextToContent} from "./contentAggregator.js";
import {globalConfig} from "./config";
import {getUserData} from "./user/management";
import {
lessonTypesMapping,
ownedContentTypes
} from "../contentTypeConfig";
import {getPermissionsAdapter} from "./permissions/index.ts";
import {MEMBERSHIP_PERMISSIONS} from "../constants/membership-permissions.ts";
export async function getLessonContentRows (brand='drumeo', pageName = 'lessons') {
const [recentContentIds, rawContentRows, userData] = await Promise.all([
fetchRecent(brand, pageName, { progress: 'recent', limit: 10 }),
getContentRows(brand, pageName),
getUserData()
])
const contentRows = Array.isArray(rawContentRows) ? rawContentRows : []
// Only fetch owned content if user has no active membership
if (!userData?.has_active_membership) {
const type = ownedContentTypes[pageName] || []
const ownedContent = await fetchOwnedContent(brand, { type })
if (ownedContent?.entity && ownedContent.entity.length > 0) {
contentRows.unshift({
id: 'owned',
title: 'Owned ' + capitalizeFirstLetter(pageName),
items: ownedContent.entity
})
}
}
// Add recent content row
contentRows.unshift({
id: 'recent',
title: 'Recent ' + capitalizeFirstLetter(pageName),
items: recentContentIds || []
})
const results = await Promise.all(
contentRows.map(async (row) => {
return { id: row.id, title: row.title, items: row.items }
})
)
return results
}
/**
* Get data that should be displayed for a specific tab with pagination
* @param {string} brand - The brand for which to fetch data.
* @param {string} pageName - The page name (e.g., 'lessons', 'songs').
* @param {string} tabName - The name for the selected tab. Should be same name received from fetchMetadata (e.g., 'Individuals', 'Collections','For You').
* @param {Object} params - Parameters for pagination, sorting, and filter.
* @param {number} [params.page=1] - The page number for pagination.
* @param {number} [params.limit=10] - The number of items per page.
* @param {string} [params.sort="-published_on"] - The field to sort the data by.
* @param {Array<string>} [params.selectedFilters=[]] - The selected filter.
* @returns {Promise<Object|null>} - The fetched content data or null if not found.
*
* @example
* getTabResults('drumeo', 'lessons','Singles', {
* page: 2,
* limit: 20,
* sort: '-popularity',
* includedFields: ['difficulty,Intermediate'],
* })
* .then(content => console.log(content))
* .catch(error => console.error(error));
*/
export async function getTabResults(brand, pageName, tabName, {
page = 1,
limit = 10,
sort = 'recommended',
selectedFilters = []
} = {}) {
if (!tabName && ['lessons', 'songs'].includes(pageName)) return { type: TabResponseType.CATALOG, data: [], meta: { filters: [], sort: {} } }
// Extract and handle 'progress' filter separately
const progressFilter = selectedFilters.find(f => f.startsWith('progress,')) || 'progress,all';
const progressValue = progressFilter.split(',')[1].toLowerCase();
const filteredSelectedFilters = selectedFilters.filter(f => !f.startsWith('progress,'));
// Prepare included fields
const tabMatch = Object.values(Tabs).find(
tabObj => tabObj.name.toLowerCase() === tabName.toLowerCase()
)
const tabValue = tabMatch?.value || ''
const tabRecSysSection = tabMatch?.recSysSection || ''
const mergedIncludedFields = tabValue ? [...filteredSelectedFilters, tabValue] : filteredSelectedFilters;
// Fetch data
let results
if( tabName === Tabs.ForYou.name ) {
results = await addContextToContent(getLessonContentRows, brand, pageName, {
dataField: 'items',
addNextLesson: true,
addNavigateTo: true,
addProgressPercentage: true,
addProgressStatus: true
})
} else if (sort === 'recommended' && tabName.toLowerCase() !== Tabs.ExploreAll.name.toLowerCase()) {
const contentTypes = lessonTypesMapping[tabName.toLowerCase()] || []
const allRecommendations = await recommendations(brand, { contentTypes, section: tabRecSysSection })
let contentToDisplay
if (allRecommendations.length > 0) {
// Fetch and sort recommended content
let recommendedContent = await fetchByRailContentIds(allRecommendations, 'tab-data', brand, true)
recommendedContent.sort((a, b) => allRecommendations.indexOf(a.id) - allRecommendations.indexOf(b.id))
recommendedContent = filterCoursesInCourseCollections(recommendedContent)
const start = (page - 1) * limit
const end = start + limit
const pagesFilledByRec = Math.floor(recommendedContent.length / limit)
// use pagination to only fetch new contents
if (recommendedContent.length < end) {
const tabData = await fetchTabData(brand, pageName, {
page: page - pagesFilledByRec,
limit,
sort: '-published_on',
includedFields: mergedIncludedFields,
progress: progressValue,
excludeIds: recommendedContent.map(c => c.id)
})
// Filter out duplicates and combine
const recommendedIds = new Set(recommendedContent.map(c => c.id))
const additionalContent = tabData.entity.filter(c => !recommendedIds.has(c.id))
const recommendedContentToDisplay = recommendedContent.slice(start, end)
const additionalContentToDisplay = additionalContent.slice(0, limit - recommendedContentToDisplay.length)
contentToDisplay = [...recommendedContentToDisplay, ...additionalContentToDisplay]
} else {
contentToDisplay = recommendedContent.slice(start, end)
}
} else {
// No recommendations - use normal flow
const temp = await fetchTabData(brand, pageName, {
page,
limit,
sort: '-published_on',
includedFields: mergedIncludedFields,
progress: progressValue
})
contentToDisplay = temp.entity
}
results = await addContextToContent(() => contentToDisplay, {
addNextLesson: true,
addNavigateTo: true,
addProgressPercentage: true,
addProgressStatus: true
})
} else {
let temp = await fetchTabData(brand, pageName, { page, limit, sort, includedFields: mergedIncludedFields, progress: progressValue });
const [ranking, contextResults] = await Promise.all([
sort === 'recommended' ? rankItems(brand, temp.entity.map(e => e.id)) : [],
addContextToContent(() => temp.entity, {
addNextLesson: true,
addNavigateTo: true,
addProgressPercentage: true,
addProgressStatus: true
})
]);
results = ranking.length === 0 ? contextResults : contextResults.sort((a, b) => {
const indexA = ranking.indexOf(a.id);
const indexB = ranking.indexOf(b.id);
return (indexA === -1 ? Infinity : indexA) - (indexB === -1 ? Infinity : indexB);
})
}
// Fetch metadata
const metaData = await fetchMetadata(brand, pageName, { skipTabFiltering: true });
// Process filters
const filters = (metaData.filters ?? []).map(filter => ({
...filter,
items: filter.items.map(item => {
const value = item.value.split(',')[1];
return {
...item,
selected: selectedFilters.includes(`${filter.key},${value}`) ||
(filter.key === 'progress' && value === 'all' && !selectedFilters.some(f => f.startsWith('progress,')))
};
})
}));
// Process sort options
const sortOptions = {
title: metaData.sort?.title ?? 'Sort By',
type: metaData.sort?.type ?? 'radio',
items: (metaData.sort?.items ?? []).map(option => ({
...option,
selected: option.value === sort
}))
};
return {
type: tabName === Tabs.ForYou.name ? TabResponseType.SECTIONS : TabResponseType.CATALOG,
data: results,
meta: { filters, sort: sortOptions }
};
}
/**
* Fetches recent content for a given brand and page with pagination.
*
* @param {string} brand - The brand for which to fetch data.
* @param {string} pageName - The page name (e.g., 'all', 'incomplete', 'completed').
* @param {string} [tabName='all'] - The tab name (defaults to 'all' for recent content).
* @param {Object} params - Parameters for pagination and sorting.
* @param {number} [params.page=1] - The page number for pagination.
* @param {number} [params.limit=10] - The number of items per page.
* @param {string} [params.sort="-published_on"] - The field to sort the data by.
* @returns {Promise<Object>} - The fetched content data.
*
* @example
* getRecent('drumeo', 'lessons', 'all', {
* page: 2,
* limit: 15,
* sort: '-popularity'
* })
* .then(content => console.log(content))
* .catch(error => console.error(error));
*/
export async function getRecent(brand, pageName, tabName = 'all', {
page = 1,
limit = 10,
sort = '-published_on',
} = {}) {
const progress = tabName.toLowerCase() == 'all' ? 'recent':tabName.toLowerCase();
const recentContentIds = await fetchRecent(brand, pageName, { page:page, limit:limit, progress: progress });
const metaData = await fetchMetadata(brand, 'recent');
return {
type: TabResponseType.CATALOG,
data: recentContentIds,
meta: { tabs: metaData.tabs }
};
}
/**
* Fetches content rows for a given brand and page with optional filtering by content row slug.
*
* @param {string} brand - The brand for which to fetch content rows.
* @param {string} pageName - The page name (e.g., 'lessons', 'songs').
* @param {string|null} contentRowSlug - The specific content row ID to fetch.
* @param {Object} params - Parameters for pagination.
* @param {number} [params.page=1] - The page number for pagination.
* @param {number} [params.limit=10] - The maximum number of content items per row.
* @returns {Promise<Object>} - The fetched content rows with complete Sanity data instead of just content IDs.
* When contentRowId is provided, returns an object with type, data, and meta properties.
*
* @example
* getContentRows('drumeo', 'lessons', 'Your-Daily-Warmup', {
* page: 1,
* limit: 5
* })
* .then(content => console.log(content))
* .catch(error => console.error(error));
*/
export async function getContentRows(brand, pageName, contentRowSlug = null, {
page = 1,
limit = 10
} = {}) {
const sanityData = await fetchContentRows(brand, pageName, contentRowSlug)
if (!sanityData) {
return []
}
let contentMap = {}
let recData = {}
let slugNameMap = {}
for (const category of sanityData) {
recData[category.slug] = category.content.map(item => item.id)
for (const content of category.content) {
contentMap[content.id] = content
}
slugNameMap[category.slug] = category.name
}
const start = (page - 1) * limit
const end = start + limit
const sortedData = await rankCategories(brand, recData)
let finalData = []
for (const category of sortedData) {
finalData.push( {
id: category.slug,
title: slugNameMap[category.slug],
items: category.items.slice(start, end).map(id => contentMap[id])})
}
return contentRowSlug ?
{
type: TabResponseType.CATALOG,
data: finalData[0].items,
meta: {}
}
: finalData
}
/**
* Fetches new and upcoming releases for a given brand with pagination options.
*
* @param {string} brand - The brand for which to fetch new and upcoming releases.
* @param {Object} [params={}] - Pagination parameters.
* @param {number} [params.page=1] - The page number for pagination.
* @param {number} [params.limit=10] - The maximum number of content items to fetch.
* @returns {Promise<{ data: Object[] } | null>} - A promise that resolves to the fetched content data or `null` if no data is found.
*
* @example
* // Fetch the first page with 10 results
* getNewAndUpcoming('drumeo')
* .then(response => console.log(response))
* .catch(error => console.error(error));
*
* @example
* // Fetch the second page with 20 results
* getNewAndUpcoming('drumeo', { page: 2, limit: 20 })
* .then(response => console.log(response))
* .catch(error => console.error(error));
*/
export async function getNewAndUpcoming(brand, { page = 1, limit = 10 } = {}) {
const data = await addContextToContent(
fetchScheduledAndNewReleases,
brand,
{ page: page, limit: limit },
{
addNavigateTo: true,
addProgressPercentage: true,
addProgressStatus: true,
}
)
if (!data) {
return null
}
return {
data: data,
}
}
/**
* Fetches scheduled content rows for a given brand with optional filtering by content row ID.
*
* @param {string} brand - The brand for which to fetch content rows.
* @param {string} [contentRowId=null] - The specific content row ID to fetch (optional).
* @param {Object} [params={}] - Pagination parameters.
* @param {number} [params.page=1] - The page number for pagination.
* @param {number} [params.limit=10] - The maximum number of content items per row.
* @returns {Promise<Object>} - A promise that resolves to the fetched content rows.
*
* @example
* // Fetch all sections with default pagination
* getScheduleContentRows('drumeo')
* .then(content => console.log(content))
* .catch(error => console.error(error));
*
* @example
* // Fetch only the 'New-Releases' section with custom pagination
* getScheduleContentRows('drumeo', 'New-Releases', { page: 1, limit: 30 })
* .then(content => console.log(content))
* .catch(error => console.error(error));
*
* @example
* // Fetch only the 'Live-Streams' section with unlimited results
* getScheduleContentRows('drumeo', 'Live-Streams')
* .then(content => console.log(content))
* .catch(error => console.error(error));
*/
export async function getScheduleContentRows(brand, contentRowId = null, { page = 1, limit = 10 } = {}) {
const sections = {
'New-Releases': {
title: 'New Releases',
fetchMethod: fetchNewReleases
},
'Live-Streams': {
title: 'Live Streams',
fetchMethod: fetchUpcomingEvents
},
'Upcoming-Releases': {
title: 'Upcoming Releases',
fetchMethod: fetchScheduledReleases
},
'Returning-Soon': {
title: 'Returning Soon',
fetchMethod: fetchReturning
},
'Leaving-Soon': {
title: 'Leaving Soon',
fetchMethod: fetchLeaving
}
};
if (contentRowId) {
if (!sections[contentRowId]) {
return null; // Return null if the requested section does not exist
}
const items = await sections[contentRowId].fetchMethod(brand, { page, limit });
// Fetch only the requested section
const result = {
id: contentRowId,
title: sections[contentRowId].title,
// TODO: Remove content after FE/MA updates the existing code to use items
content: items,
items: items
};
return {
type: TabResponseType.CATALOG,
data: result,
meta: {}
};
}
// If no specific contentRowId, fetch all sections
const results = await Promise.all(
Object.entries(sections).map(async ([id, section]) => {
// Apply special pagination rules
const isNewReleases = id === 'New-Releases';
const pagination = isNewReleases ? { page: 1, limit: 30 } : { page: 1, limit: Number.MAX_SAFE_INTEGER };
const items = await section.fetchMethod(brand, pagination)
const content = await addContextToContent(() => items, {
addProgressPercentage: true,
addProgressStatus: true,
addNavigateTo: true,
})
return {
id,
title: section.title,
// TODO: Remove content after FE/MA updates the existing code to use items
content: content,
items: content
};
})
);
return {
type: TabResponseType.SECTIONS,
data: results,
meta: {}
};
}
/**
* Fetches recommended content for a given brand with pagination support.
*
* @param {string} brand - The brand for which to fetch recommended content.
* @param {Object} [params={}] - Pagination parameters.
* @param {number} [params.page=1] - The page number for pagination.
* @param {number} [params.limit=10] - The maximum number of recommended content items per page.
* @returns {Promise<Object>} - A promise that resolves to an object containing recommended content.
*
* @example
* // Fetch recommended content for a brand with default pagination
* getRecommendedForYou('drumeo')
* .then(content => console.log(content))
* .catch(error => console.error(error));
*
* @example
* // Fetch recommended content for a brand with custom pagination
* getRecommendedForYou('drumeo', { page: 2, limit: 5 })
* .then(content => console.log(content))
* .catch(error => console.error(error));
*/
export async function getRecommendedForYou(brand, rowId = null, {
page = 1,
limit = 10,
} = {}) {
const requiredItems = page * limit;
const data = await recommendations( brand, {limit: requiredItems})
const title = brand === 'playbass' ? "You Might Like" : "Recommended For You"
if (!data || !data.length) {
return { id: 'recommended', title: title, items: [] };
}
// Apply pagination before calling fetchByRailContentIds
const startIndex = (page - 1) * limit;
const paginatedData = data.slice(startIndex, startIndex + limit);
const contents = await addContextToContent(fetchByRailContentIds, paginatedData, 'tab-data', brand, true,
{
addNextLesson: true,
addNavigateTo: true,
})
if (rowId) {
return {
type: TabResponseType.CATALOG,
data: contents,
meta: {}
};
}
return { id: 'recommended', title: title, items: contents }
}
/**
* Fetches legacy methods for a given brand by permission.
*
* @param {string} brand - The brand for which to fetch legacy methods.
* @returns {Promise<Object>} - A promise that resolves to an object containing legacy methods.
*
* @example
* // Fetch legacy methods for a brand by permission
* getLegacyMethods('drumeo')
* .then(content => console.log(content))
* .catch(error => console.error(error));
*/
export async function getLegacyMethods(brand)
{
const brandMap = {
drumeo: [241247],
pianote: [
276693,
215952 //Foundations 2019
],
singeo: [308514],
guitareo: [333652],
}
const ids = brandMap[brand] ?? null;
if (!ids) return [];
const adapter = getPermissionsAdapter()
const userPermissionsData = await adapter.fetchUserPermissions()
const userPermissions = userPermissionsData.permissions
// Users should only have access to this if they have an active membership AS WELL as the content access
// This is hardcoded behaviour and isn't found elsewhere
const hasMembership = userPermissionsData.isAdmin
|| userPermissions.includes(MEMBERSHIP_PERMISSIONS.base)
|| userPermissions.includes(MEMBERSHIP_PERMISSIONS.plus)
const hasContentPermission = userPermissions.includes(100000000 + ids[0])
if (hasMembership && hasContentPermission) {
return Promise.all(ids.map(id => fetchCourseCollectionData(id)))
} else {
return []
}
}
/**
* Fetches content owned by the user (excluding membership content).
* Shows only content accessible through purchases/entitlements, not through membership.
*
* @param {string} brand - The brand for which to fetch owned content.
* @param {Object} [params={}] - Parameters for pagination and sorting.
* @param {Array<string>} [params.type=[]] - Content type(s) to filter (optional array).
* @param {number} [params.page=1] - The page number for pagination.
* @param {number} [params.limit=10] - The number of items per page.
* @param {string} [params.sort='-published_on'] - The field to sort the data by.
* @returns {Promise<Object>} - The fetched owned content with entity array and total count.
*
* @example
* // Fetch all owned content with default pagination
* getOwnedContent('drumeo')
* .then(content => console.log(content))
* .catch(error => console.error(error));
*
* @example
* // Fetch owned content with custom pagination and sorting
* getOwnedContent('drumeo', {
* page: 2,
* limit: 20,
* sort: '-published_on'
* })
* .then(content => console.log(content))
* .catch(error => console.error(error));
*
* @example
* // Fetch owned content filtered by types
* getOwnedContent('drumeo', {
* type: ['course', 'course-collection'],
* page: 1,
* limit: 10
* })
* .then(content => console.log(content))
* .catch(error => console.error(error));
*/
export async function getOwnedContent(brand, {
type = [],
page = 1,
limit = 10,
sort = '-published_on',
} = {}) {
const data = await fetchOwnedContent(brand, { type, page, limit, sort });
if (!data) {
return {
entity: [],
total: 0
};
}
return await addContextToContent(() => data, {
dataField: 'entity',
addNavigateTo: true,
addProgressPercentage: true,
addProgressStatus: true,
});
}
export function filterCoursesInCourseCollections(data) {
return data.filter(c => !(c.type === 'course' && c.parent_id))
}