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.
@@ -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 '@/services/user/onboarding'
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 ? `&section=${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
+ }
@@ -33,7 +33,7 @@ import {
33
33
  transcriptionsLessonTypes,
34
34
  tutorialsLessonTypes,
35
35
  } from '../contentTypeConfig.js'
36
- import { fetchSimilarItems } from './recommendations.js'
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
- if (typeof resumeTime != 'undefined') {
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
  }
@@ -1 +1 @@
1
- export const MCS_VERSION = '2.185.1'
1
+ export const MCS_VERSION = '2.186.1'