musora-content-services 2.185.1 → 2.186.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +6 -1
- package/CHANGELOG.md +17 -0
- package/jest.config.js +0 -1
- package/package.json +1 -1
- package/src/index.d.ts +15 -2
- package/src/index.js +15 -2
- package/src/lib/ads/async-either.ts +148 -0
- package/src/lib/ads/coproduct.ts +36 -0
- package/src/lib/sanity/decorators/navigate-to.ts +20 -14
- package/src/lib/sanity/examples.ts +27 -31
- package/src/lib/sanity/filter.ts +62 -0
- package/src/lib/sanity/groq.ts +60 -19
- package/src/lib/sanity/runner.ts +3 -2
- package/src/lib/sanity/types/marketing.d.ts +33 -0
- package/src/services/awards/internal/award-definitions.js +4 -0
- package/src/services/content.js +181 -191
- package/src/services/contentProgress.js +19 -2
- package/src/services/endScreen/endScreen.ts +1 -1
- package/src/services/marketing/promotions.ts +178 -0
- package/src/services/my-path/placement-quiz.ts +1 -1
- package/src/services/progress/mutations.ts +28 -2
- package/src/services/recommendations.ts +307 -0
- package/src/services/sanity.js +1 -1
- package/src/services/sync/repositories/content-progress.ts +26 -5
- package/src/version-info.js +1 -1
- package/src/services/recommendations.js +0 -142
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { GET, POST } from '../../infrastructure/http/HttpClient'
|
|
2
|
+
import { decorateAsync } from '../../lib/sanity/decorators/base'
|
|
3
|
+
import type { Decoratable, FieldDecoratorAsync } from '../../lib/sanity/decorators/base'
|
|
4
|
+
import { Filters as f } from '../../lib/sanity/filter'
|
|
5
|
+
import { groq } from '../../lib/sanity/groq'
|
|
6
|
+
import type { GroqBuilder } from '../../lib/sanity/groq'
|
|
7
|
+
import type { PromotionalCampaignDocument } from '../../lib/sanity/types/marketing'
|
|
8
|
+
import { SanityQueryError } from '../../lib/sanity/runner'
|
|
9
|
+
import { AsyncEither } from '../../lib/ads/async-either'
|
|
10
|
+
|
|
11
|
+
const excludeFromGeneratedIndex = [
|
|
12
|
+
'campaignQuery',
|
|
13
|
+
'campaignProjection',
|
|
14
|
+
'userCampaignStateDecorator',
|
|
15
|
+
'decorateUserCampaignState',
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
// ============================================
|
|
19
|
+
// TYPES
|
|
20
|
+
// ============================================
|
|
21
|
+
|
|
22
|
+
export const USER_CAMPAIGN_STATE_FIELD = 'user_state' as const
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A campaign's placements are authored in Sanity, but whether a user still sees one is answered by
|
|
26
|
+
* the backend. `decorateUserCampaignState` attaches the second to the first.
|
|
27
|
+
*/
|
|
28
|
+
export interface UserCampaignState {
|
|
29
|
+
claimed: boolean
|
|
30
|
+
dismissed: boolean
|
|
31
|
+
eligible: boolean
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type WithUserCampaignState<T extends PromotionalCampaignDocument> = T & {
|
|
35
|
+
user_state: UserCampaignState
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface CampaignQueryOptions {
|
|
39
|
+
/** Restricts the query to one campaign, or to any of several. */
|
|
40
|
+
slug?: string | string[]
|
|
41
|
+
/** The moment the campaign window is tested against. Defaults to now. */
|
|
42
|
+
on?: Date
|
|
43
|
+
/** Set false to return campaigns whose window does not cover `on`. */
|
|
44
|
+
running?: boolean
|
|
45
|
+
/** Set false to include campaigns that are toggled off in Sanity. */
|
|
46
|
+
active?: boolean
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const UNTOUCHED: UserCampaignState = { claimed: false, dismissed: false, eligible: true }
|
|
50
|
+
|
|
51
|
+
// ============================================
|
|
52
|
+
// UTILITIES
|
|
53
|
+
// ============================================
|
|
54
|
+
|
|
55
|
+
const filterCampaignsForUser = (campaigns: WithUserCampaignState<PromotionalCampaignDocument>[]) =>
|
|
56
|
+
campaigns.filter(
|
|
57
|
+
(campaign) =>
|
|
58
|
+
campaign.user_state.eligible && !campaign.user_state.claimed && !campaign.user_state.dismissed
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
// ============================================
|
|
62
|
+
// QUERY BUILDING
|
|
63
|
+
// ============================================
|
|
64
|
+
|
|
65
|
+
export const campaignProjection: string[] = [
|
|
66
|
+
'...',
|
|
67
|
+
'placements[]{ ..., "dark_mode_image": dark_mode_image.asset->url, "light_mode_image": light_mode_image.asset->url }',
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
function slugFilter(slug: CampaignQueryOptions['slug']): string {
|
|
71
|
+
const slugs = (Array.isArray(slug) ? slug : [slug]).filter(Boolean) as string[]
|
|
72
|
+
|
|
73
|
+
if (!slugs.length) return f.empty
|
|
74
|
+
|
|
75
|
+
return slugs.length === 1 ? f.slug(slugs[0]) : f.slugIn(slugs)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function campaignQuery(
|
|
79
|
+
options: CampaignQueryOptions = {}
|
|
80
|
+
): GroqBuilder<PromotionalCampaignDocument[]> {
|
|
81
|
+
const { slug, on = new Date(), running = true, active = true } = options
|
|
82
|
+
|
|
83
|
+
return groq<PromotionalCampaignDocument[]>()
|
|
84
|
+
.and(
|
|
85
|
+
f.combine(
|
|
86
|
+
f.type('promotional-campaign'),
|
|
87
|
+
active && f.flag('active'),
|
|
88
|
+
running && f.dateWithin('starts_at', 'ends_at', on.toISOString()),
|
|
89
|
+
slugFilter(slug)
|
|
90
|
+
)
|
|
91
|
+
)
|
|
92
|
+
.order('starts_at desc')
|
|
93
|
+
.select(...campaignProjection)
|
|
94
|
+
.default([])
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ============================================
|
|
98
|
+
// FETCHING
|
|
99
|
+
// ============================================
|
|
100
|
+
|
|
101
|
+
export async function fetchPromotionalCampaigns(
|
|
102
|
+
options: CampaignQueryOptions = {}
|
|
103
|
+
): Promise<PromotionalCampaignDocument[]> {
|
|
104
|
+
return campaignQuery(options).run().recover([])
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Every running campaign the user has not already claimed or dismissed, and is eligible.
|
|
109
|
+
*/
|
|
110
|
+
export async function fetchAvailablePromotionalCampaigns(
|
|
111
|
+
options: CampaignQueryOptions = {}
|
|
112
|
+
): Promise<WithUserCampaignState<PromotionalCampaignDocument>[]> {
|
|
113
|
+
return campaignQuery(options)
|
|
114
|
+
.run()
|
|
115
|
+
.flatMap(decorateUserCampaignState)
|
|
116
|
+
.map(filterCampaignsForUser)
|
|
117
|
+
.recover([])
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Fetches the state of every given campaign in a single request, keyed by slug. The backend leaves
|
|
122
|
+
* out slugs it has no campaign for, so a missing key means the campaign is unknown to it.
|
|
123
|
+
*/
|
|
124
|
+
export async function fetchUserCampaignStates(
|
|
125
|
+
slugs: string[]
|
|
126
|
+
): Promise<Record<string, UserCampaignState>> {
|
|
127
|
+
if (!slugs.length) return {}
|
|
128
|
+
|
|
129
|
+
const query = slugs.map((slug) => `slugs[]=${encodeURIComponent(slug)}`).join('&')
|
|
130
|
+
|
|
131
|
+
return (await GET(`/api/promotions/v1/campaigns/state?${query}`)) ?? {}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ============================================
|
|
135
|
+
// DECORATION
|
|
136
|
+
// ============================================
|
|
137
|
+
|
|
138
|
+
export function userCampaignStateDecorator(
|
|
139
|
+
states: Record<string, UserCampaignState>
|
|
140
|
+
): FieldDecoratorAsync<Decoratable, typeof USER_CAMPAIGN_STATE_FIELD, UserCampaignState> {
|
|
141
|
+
return {
|
|
142
|
+
field: USER_CAMPAIGN_STATE_FIELD,
|
|
143
|
+
compute: async (item) => {
|
|
144
|
+
const campaign = item as unknown as PromotionalCampaignDocument
|
|
145
|
+
|
|
146
|
+
return states[campaign.slug?.current] ?? UNTOUCHED
|
|
147
|
+
},
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Attaches each campaign's claim and dismiss state in a single backend request.
|
|
153
|
+
*/
|
|
154
|
+
export function decorateUserCampaignState<T extends PromotionalCampaignDocument>(
|
|
155
|
+
campaigns: T[]
|
|
156
|
+
): AsyncEither<Error, WithUserCampaignState<T>[]> {
|
|
157
|
+
return AsyncEither.attempt(async () => {
|
|
158
|
+
if (!campaigns.length) return []
|
|
159
|
+
|
|
160
|
+
const states = await fetchUserCampaignStates(campaigns.map((campaign) => campaign.slug.current))
|
|
161
|
+
const { field, compute } = userCampaignStateDecorator(states)
|
|
162
|
+
const decorated = await decorateAsync(campaigns as unknown as Decoratable[], field, compute)
|
|
163
|
+
|
|
164
|
+
return decorated as unknown as WithUserCampaignState<T>[]
|
|
165
|
+
})
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ============================================
|
|
169
|
+
// MUTATIONS
|
|
170
|
+
// ============================================
|
|
171
|
+
|
|
172
|
+
export async function claimPromotionalCampaign(slug: string): Promise<void> {
|
|
173
|
+
await POST(`/api/promotions/v1/campaigns/${encodeURIComponent(slug)}/claim`)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export async function dismissPromotionalCampaign(slug: string): Promise<void> {
|
|
177
|
+
await POST(`/api/promotions/v1/campaigns/${encodeURIComponent(slug)}/dismiss`)
|
|
178
|
+
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import { HttpClient } from '../../infrastructure/http/HttpClient'
|
|
5
5
|
import { globalConfig } from '../config.js'
|
|
6
|
-
import { OnboardingRecommendedContent } from '
|
|
6
|
+
import { OnboardingRecommendedContent } from '../user/onboarding'
|
|
7
7
|
|
|
8
8
|
|
|
9
9
|
const baseUrl = `/api/my-path/v1/placement-quiz`
|
|
@@ -67,14 +67,40 @@ export const save = async (
|
|
|
67
67
|
progress: number,
|
|
68
68
|
collection?: CollectionParameter,
|
|
69
69
|
currentSeconds?: number,
|
|
70
|
+
options: SaveOptions = {}
|
|
71
|
+
) => {
|
|
72
|
+
const activeCollection = withDefaultCollection(collection)
|
|
73
|
+
const response = await saveProgressPercent(contentId, progress, activeCollection, currentSeconds, options)
|
|
74
|
+
await saveResumeTime(contentId, activeCollection, currentSeconds)
|
|
75
|
+
return response
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// resume time is last-write-wins, so it bypasses the percent gate that drops regressed progress
|
|
79
|
+
const saveResumeTime = async (
|
|
80
|
+
contentId: number,
|
|
81
|
+
collection: CollectionParameter,
|
|
82
|
+
currentSeconds: number | undefined
|
|
83
|
+
): Promise<void> => {
|
|
84
|
+
if (currentSeconds === undefined || currentSeconds === null) return
|
|
85
|
+
|
|
86
|
+
await db.contentProgress.updateResumeTime(contentId, collection, currentSeconds, { skipPush: true })
|
|
87
|
+
if (isPlaylistCollection(collection)) {
|
|
88
|
+
await db.contentProgress.updateResumeTime(contentId, null, currentSeconds, { skipPush: true })
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const saveProgressPercent = async (
|
|
93
|
+
contentId: number,
|
|
94
|
+
progress: number,
|
|
95
|
+
activeCollection: CollectionParameter,
|
|
96
|
+
currentSeconds: number | undefined,
|
|
70
97
|
{
|
|
71
98
|
isOffline = false,
|
|
72
99
|
hierarchy = null,
|
|
73
100
|
skipPush = false,
|
|
74
101
|
accessedDirectly = true,
|
|
75
|
-
}: SaveOptions
|
|
102
|
+
}: SaveOptions
|
|
76
103
|
) => {
|
|
77
|
-
const activeCollection = withDefaultCollection(collection)
|
|
78
104
|
const isPlaylist = isPlaylistCollection(activeCollection)
|
|
79
105
|
|
|
80
106
|
let progresses: Record<number, number> = { [contentId]: progress }
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module Recommendations
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { TabResponseType } from '../contentMetaData.js'
|
|
6
|
+
import { getFieldsForContentTypeWithFilteredChildren } from '../contentTypeConfig.js'
|
|
7
|
+
import { decorateAll, type FieldDecorator } from '../lib/sanity/decorators/base'
|
|
8
|
+
import { accessDecorator, type WithNeedAccess } from '../lib/sanity/decorators/need-access'
|
|
9
|
+
import {
|
|
10
|
+
lifetimeUpgradeDecorator,
|
|
11
|
+
type WithNeedLifetimeUpgrade,
|
|
12
|
+
} from '../lib/sanity/decorators/need-lifetime-upgrade'
|
|
13
|
+
import { decorateNavigateTo, type WithNavigateTo } from '../lib/sanity/decorators/navigate-to'
|
|
14
|
+
import { pageTypeDecorator, type WithPageType } from '../lib/sanity/decorators/page-type'
|
|
15
|
+
import { Filters as f } from '../lib/sanity/filter'
|
|
16
|
+
import { groq } from '../lib/sanity/groq'
|
|
17
|
+
import { globalConfig } from './config.js'
|
|
18
|
+
import { GET, HttpClient } from '../infrastructure/http/HttpClient'
|
|
19
|
+
import { fetchUserPermissions, UserPermissions } from './permissions/index'
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @type {string[]}
|
|
23
|
+
*/
|
|
24
|
+
const excludeFromGeneratedIndex = []
|
|
25
|
+
|
|
26
|
+
const RECOMMENDED_ROW_ID = 'recommended'
|
|
27
|
+
const RECOMMENDED_CONTENT_TYPE = 'tab-data'
|
|
28
|
+
|
|
29
|
+
const RECOMMENDER_URL = 'https://recommender.musora.com'
|
|
30
|
+
const recommenderClient = new HttpClient(RECOMMENDER_URL, null, null, null, 'omit')
|
|
31
|
+
|
|
32
|
+
interface RecommendedContent {
|
|
33
|
+
id: number
|
|
34
|
+
type: string
|
|
35
|
+
brand: string
|
|
36
|
+
thumbnail: string
|
|
37
|
+
published_on: string | null
|
|
38
|
+
status: string
|
|
39
|
+
live_event_start_time?: string | null
|
|
40
|
+
live_event_end_time?: string | null
|
|
41
|
+
children?: RecommendedContent[]
|
|
42
|
+
[key: string]: unknown
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
type DecoratedRecommendedContent = WithNeedLifetimeUpgrade<
|
|
46
|
+
WithPageType<WithNeedAccess<RecommendedContent>>
|
|
47
|
+
> & { isLive: boolean }
|
|
48
|
+
|
|
49
|
+
type NavigableRecommendedContent = WithNavigateTo<DecoratedRecommendedContent>
|
|
50
|
+
|
|
51
|
+
interface RecommendedRow {
|
|
52
|
+
id: string
|
|
53
|
+
title: string
|
|
54
|
+
items: NavigableRecommendedContent[]
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface RecommendedCatalog {
|
|
58
|
+
type: string
|
|
59
|
+
data: NavigableRecommendedContent[]
|
|
60
|
+
meta: Record<string, never>
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface RecommendedForYouOptions {
|
|
64
|
+
page?: number
|
|
65
|
+
limit?: number
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface RecommendationsOptions {
|
|
69
|
+
section?: string
|
|
70
|
+
contentTypes?: string[]
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface RankedCategory {
|
|
74
|
+
slug: string
|
|
75
|
+
items: number[]
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export type CategoriesToRank = Record<string, number[]>
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* @param {number|string} contentId
|
|
82
|
+
* @param {string} brand
|
|
83
|
+
* @param {number} [count=10]
|
|
84
|
+
* @returns {Promise<number[]|null>}
|
|
85
|
+
* @example
|
|
86
|
+
* fetchSimilarItems(1113, 'drumeo')
|
|
87
|
+
* .then(ids => console.log(ids))
|
|
88
|
+
* .catch(error => console.error(error));
|
|
89
|
+
*/
|
|
90
|
+
export async function fetchSimilarItems(
|
|
91
|
+
contentId: number | string,
|
|
92
|
+
brand: string,
|
|
93
|
+
count: number = 10
|
|
94
|
+
): Promise<number[] | null> {
|
|
95
|
+
if (!contentId) {
|
|
96
|
+
return []
|
|
97
|
+
}
|
|
98
|
+
const id = parseInt(String(contentId))
|
|
99
|
+
const data = {
|
|
100
|
+
brand,
|
|
101
|
+
content_ids: id,
|
|
102
|
+
num_similar: count + 1,
|
|
103
|
+
page_size: count + 1,
|
|
104
|
+
page: 1,
|
|
105
|
+
exclude_interacted: true,
|
|
106
|
+
}
|
|
107
|
+
try {
|
|
108
|
+
const response = await recommenderClient.post('/similar_items/', data)
|
|
109
|
+
return response['similar_items'].filter((item: number) => item !== id).slice(0, count)
|
|
110
|
+
} catch (error) {
|
|
111
|
+
console.error('Fetch error:', error)
|
|
112
|
+
return null
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* @param {string} brand
|
|
118
|
+
* @param {CategoriesToRank} categories
|
|
119
|
+
* @returns {Promise<RankedCategory[]>}
|
|
120
|
+
* @example
|
|
121
|
+
* rankCategories('drumeo', { 1: [111222, 23120], 2: [2222, 33333] })
|
|
122
|
+
* .then(categories => console.log(categories))
|
|
123
|
+
* .catch(error => console.error(error));
|
|
124
|
+
*/
|
|
125
|
+
export async function rankCategories(
|
|
126
|
+
brand: string,
|
|
127
|
+
categories: CategoriesToRank
|
|
128
|
+
): Promise<RankedCategory[]> {
|
|
129
|
+
const data = {
|
|
130
|
+
brand,
|
|
131
|
+
user_id: globalConfig.sessionConfig.userId,
|
|
132
|
+
playlists: categories,
|
|
133
|
+
}
|
|
134
|
+
try {
|
|
135
|
+
const response = await recommenderClient.post('/rank_each_list/', data)
|
|
136
|
+
return response['ranked_playlists'].map((rankedPlaylist: any) => ({
|
|
137
|
+
slug: rankedPlaylist.playlist_id,
|
|
138
|
+
items: rankedPlaylist.ranked_items,
|
|
139
|
+
}))
|
|
140
|
+
} catch (error) {
|
|
141
|
+
console.error('RankCategories fetch error:', error)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return Object.entries(categories).map(([slug, items]) => ({ slug, items }))
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* @param {string} brand
|
|
149
|
+
* @param {number[]} contentIds
|
|
150
|
+
* @returns {Promise<number[]>}
|
|
151
|
+
* @example
|
|
152
|
+
* rankItems('drumeo', [111222, 23120, 402199])
|
|
153
|
+
* .then(ids => console.log(ids))
|
|
154
|
+
* .catch(error => console.error(error));
|
|
155
|
+
*/
|
|
156
|
+
export async function rankItems(brand: string, contentIds: number[]): Promise<number[]> {
|
|
157
|
+
if (contentIds.length === 0) {
|
|
158
|
+
return []
|
|
159
|
+
}
|
|
160
|
+
const data = {
|
|
161
|
+
brand,
|
|
162
|
+
user_id: globalConfig.sessionConfig.userId,
|
|
163
|
+
content_ids: contentIds,
|
|
164
|
+
}
|
|
165
|
+
try {
|
|
166
|
+
const response = await recommenderClient.post('/rank_items/', data)
|
|
167
|
+
return response['ranked_content_ids']
|
|
168
|
+
} catch (error) {
|
|
169
|
+
console.error('rankItems fetch error:', error)
|
|
170
|
+
return contentIds
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* @param {string} brand
|
|
176
|
+
* @param {RecommendationsOptions} [options={}]
|
|
177
|
+
* @returns {Promise<number[]>}
|
|
178
|
+
* @example
|
|
179
|
+
* recommendations('drumeo', { section: 'lessons', contentTypes: ['course'] })
|
|
180
|
+
* .then(ids => console.log(ids))
|
|
181
|
+
* .catch(error => console.error(error));
|
|
182
|
+
*/
|
|
183
|
+
export async function recommendations(
|
|
184
|
+
brand: string,
|
|
185
|
+
{ section = '', contentTypes = [] }: RecommendationsOptions = {}
|
|
186
|
+
): Promise<number[]> {
|
|
187
|
+
const sectionParam = section.toUpperCase().replace('-', '_')
|
|
188
|
+
const sectionString = sectionParam ? `§ion=${sectionParam}` : ''
|
|
189
|
+
const contentTypesString = contentTypes
|
|
190
|
+
.map((type) => `&content_types[]=${encodeURIComponent(type)}`)
|
|
191
|
+
.join('')
|
|
192
|
+
|
|
193
|
+
return await GET(
|
|
194
|
+
`/api/content/v1/recommendations?brand=${brand}${sectionString}${contentTypesString}`
|
|
195
|
+
)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const isLiveDecorator: FieldDecorator<RecommendedContent, 'isLive', boolean> = {
|
|
199
|
+
field: 'isLive',
|
|
200
|
+
recurse: false,
|
|
201
|
+
compute: (content) => {
|
|
202
|
+
const now = new Date().toISOString()
|
|
203
|
+
return Boolean(
|
|
204
|
+
content.live_event_start_time &&
|
|
205
|
+
content.live_event_end_time &&
|
|
206
|
+
content.live_event_start_time <= now &&
|
|
207
|
+
content.live_event_end_time >= now
|
|
208
|
+
)
|
|
209
|
+
},
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const uniqueIds = (ids: Array<number | null | undefined>): number[] => [
|
|
213
|
+
...new Set(ids.filter((id) => id !== null && id !== undefined) as number[]),
|
|
214
|
+
]
|
|
215
|
+
|
|
216
|
+
const sortByRecommendedOrder =
|
|
217
|
+
(ids: number[]) =>
|
|
218
|
+
<T extends { id: number }>(content: T[]): T[] =>
|
|
219
|
+
content.sort((a, b) => ids.indexOf(a.id) - ids.indexOf(b.id))
|
|
220
|
+
|
|
221
|
+
const decorateRecommendations =
|
|
222
|
+
(permissions: UserPermissions) =>
|
|
223
|
+
(contents: RecommendedContent[]): DecoratedRecommendedContent[] =>
|
|
224
|
+
decorateAll(contents, [
|
|
225
|
+
accessDecorator(permissions),
|
|
226
|
+
lifetimeUpgradeDecorator(permissions),
|
|
227
|
+
pageTypeDecorator,
|
|
228
|
+
isLiveDecorator,
|
|
229
|
+
]) as DecoratedRecommendedContent[]
|
|
230
|
+
|
|
231
|
+
async function fetchRecommendedContent(
|
|
232
|
+
ids: number[],
|
|
233
|
+
brand: string
|
|
234
|
+
): Promise<NavigableRecommendedContent[]> {
|
|
235
|
+
if (!ids.length) return []
|
|
236
|
+
|
|
237
|
+
const [restrictions, lessonCountFilter, fields, permissions] = await Promise.all([
|
|
238
|
+
f.combineAsync(
|
|
239
|
+
f.idIn(ids),
|
|
240
|
+
brand ? f.brand(brand) : f.empty,
|
|
241
|
+
f.status(),
|
|
242
|
+
f.notDeprecated(),
|
|
243
|
+
f.permissions({ showMembershipRestrictedContent: true })
|
|
244
|
+
),
|
|
245
|
+
f.combineAsync('_id in ^.child[]._ref', f.status(), f.notDeprecated(), f.permissions()),
|
|
246
|
+
getFieldsForContentTypeWithFilteredChildren(RECOMMENDED_CONTENT_TYPE, false) as Promise<
|
|
247
|
+
string[]
|
|
248
|
+
>,
|
|
249
|
+
fetchUserPermissions(),
|
|
250
|
+
])
|
|
251
|
+
|
|
252
|
+
return groq<RecommendedContent[]>()
|
|
253
|
+
.and(restrictions)
|
|
254
|
+
.select(
|
|
255
|
+
...fields,
|
|
256
|
+
`"lesson_count": coalesce(${f.count(lessonCountFilter)}, 0)`,
|
|
257
|
+
'live_event_start_time',
|
|
258
|
+
'live_event_end_time'
|
|
259
|
+
)
|
|
260
|
+
.default([])
|
|
261
|
+
.run()
|
|
262
|
+
.map(decorateRecommendations(permissions))
|
|
263
|
+
.mapAsync((contents) => decorateNavigateTo(contents))
|
|
264
|
+
.map(sortByRecommendedOrder(ids))
|
|
265
|
+
.recover([])
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* @param {string} brand
|
|
270
|
+
* @param {string|null} [rowId=null]
|
|
271
|
+
* @param {Object} [params={}]
|
|
272
|
+
* @param {number} [params.page=1]
|
|
273
|
+
* @param {number} [params.limit=10]
|
|
274
|
+
* @returns {Promise<RecommendedRow|RecommendedCatalog>}
|
|
275
|
+
* @example
|
|
276
|
+
* getRecommendedForYou('drumeo')
|
|
277
|
+
* .then(content => console.log(content))
|
|
278
|
+
* .catch(error => console.error(error));
|
|
279
|
+
* @example
|
|
280
|
+
* getRecommendedForYou('drumeo', null, { page: 2, limit: 5 })
|
|
281
|
+
* .then(content => console.log(content))
|
|
282
|
+
* .catch(error => console.error(error));
|
|
283
|
+
*/
|
|
284
|
+
export async function getRecommendedForYou(
|
|
285
|
+
brand: string,
|
|
286
|
+
rowId: string | null = null,
|
|
287
|
+
{ page = 1, limit = 10 }: RecommendedForYouOptions = {}
|
|
288
|
+
): Promise<RecommendedRow | RecommendedCatalog> {
|
|
289
|
+
const title = brand === 'playbass' ? 'You Might Like' : 'Recommended For You'
|
|
290
|
+
const data = await recommendations(brand)
|
|
291
|
+
|
|
292
|
+
if (!data || !data.length) {
|
|
293
|
+
return { id: RECOMMENDED_ROW_ID, title, items: [] }
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const offset = (page - 1) * limit
|
|
297
|
+
const contents = await fetchRecommendedContent(
|
|
298
|
+
uniqueIds(data.slice(offset, offset + limit)),
|
|
299
|
+
brand
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
if (rowId) {
|
|
303
|
+
return { type: TabResponseType.CATALOG, data: contents, meta: {} }
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return { id: RECOMMENDED_ROW_ID, title, items: contents }
|
|
307
|
+
}
|
package/src/services/sanity.js
CHANGED
|
@@ -33,7 +33,7 @@ import {
|
|
|
33
33
|
transcriptionsLessonTypes,
|
|
34
34
|
tutorialsLessonTypes,
|
|
35
35
|
} from '../contentTypeConfig.js'
|
|
36
|
-
import { fetchSimilarItems } from './recommendations
|
|
36
|
+
import { fetchSimilarItems } from './recommendations'
|
|
37
37
|
import {
|
|
38
38
|
ALWAYS_VISIBLE_TABS,
|
|
39
39
|
CONTENT_STATUSES,
|
|
@@ -12,6 +12,8 @@ interface MetadataParameter {
|
|
|
12
12
|
parent_id: number
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
const MIN_INITIAL_RESUME_TIME_SECONDS = 10
|
|
16
|
+
|
|
15
17
|
export default class ProgressRepository extends SyncRepository<ContentProgress> {
|
|
16
18
|
|
|
17
19
|
static collectionTypeFilter(
|
|
@@ -185,11 +187,7 @@ export default class ProgressRepository extends SyncRepository<ContentProgress>
|
|
|
185
187
|
r.content_type = metadata.type
|
|
186
188
|
r.content_parent_id = metadata.parent_id
|
|
187
189
|
|
|
188
|
-
|
|
189
|
-
if (resumeTime >= 10 || r.resume_time_seconds !== null) {
|
|
190
|
-
r.resume_time_seconds = Math.floor(resumeTime)
|
|
191
|
-
}
|
|
192
|
-
}
|
|
190
|
+
ProgressRepository.applyResumeTime(r, resumeTime)
|
|
193
191
|
|
|
194
192
|
if (accessedDirectly && r.collection_type === COLLECTION_TYPE.SELF) {
|
|
195
193
|
r.last_interacted_a_la_carte = r.updated_at
|
|
@@ -263,6 +261,29 @@ export default class ProgressRepository extends SyncRepository<ContentProgress>
|
|
|
263
261
|
//todo add event emitting for bulk updates?
|
|
264
262
|
}
|
|
265
263
|
|
|
264
|
+
async updateResumeTime(contentId: number, collection: CollectionParameter | null, resumeTime: number, { skipPush = false } = {}) {
|
|
265
|
+
const id = ContentProgress.generateId(contentId, collection)
|
|
266
|
+
const existing = await this.readOne(id)
|
|
267
|
+
if (!existing.data) return
|
|
268
|
+
|
|
269
|
+
const currentResumeTime = existing.data.resume_time_seconds
|
|
270
|
+
const isUnchanged = currentResumeTime === Math.floor(resumeTime)
|
|
271
|
+
if (isUnchanged || !ProgressRepository.shouldWriteResumeTime(currentResumeTime, resumeTime)) return
|
|
272
|
+
|
|
273
|
+
return this.upsertOne(id, (r) => ProgressRepository.applyResumeTime(r, resumeTime), { skipPush })
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
private static shouldWriteResumeTime(currentResumeTime: number | null, resumeTime: number) {
|
|
277
|
+
return resumeTime >= MIN_INITIAL_RESUME_TIME_SECONDS || currentResumeTime !== null
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
private static applyResumeTime(r: ContentProgress, resumeTime: number | undefined) {
|
|
281
|
+
if (typeof resumeTime == 'undefined') return
|
|
282
|
+
if (ProgressRepository.shouldWriteResumeTime(r.resume_time_seconds, resumeTime)) {
|
|
283
|
+
r.resume_time_seconds = Math.floor(resumeTime)
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
266
287
|
eraseProgress(contentId: number, collection: CollectionParameter | null, { skipPush = false } = {}) {
|
|
267
288
|
return this.deleteOne(ContentProgress.generateId(contentId, collection), { skipPush })
|
|
268
289
|
}
|
package/src/version-info.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const MCS_VERSION = '2.
|
|
1
|
+
export const MCS_VERSION = '2.186.1'
|