musora-content-services 1.0.98 → 1.0.100

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.
@@ -9,7 +9,9 @@ import {
9
9
  contentTypeConfig,
10
10
  DEFAULT_FIELDS,
11
11
  getFieldsForContentType,
12
- filtersToGroq
12
+ filtersToGroq,
13
+ getUpcomingEventsTypes,
14
+ getNewReleasesTypes,
13
15
  } from "../contentTypeConfig";
14
16
  import {globalConfig} from "./config";
15
17
 
@@ -254,14 +256,8 @@ export async function fetchWorkouts(brand) {
254
256
  * @returns {Promise<Object|null>} - The fetched new releases data or null if not found.
255
257
  */
256
258
  export async function fetchNewReleases(brand, { page = 1, limit = 10, sort="-published_on" } = {}) {
257
- const newTypes = {
258
- 'drumeo': ["drum-fest-international-2022", "spotlight", "the-history-of-electronic-drums", "backstage-secrets", "quick-tips", "question-and-answer", "student-collaborations", "live-streams", "live", "podcasts", "solos", "boot-camps", "gear-guides", "performances", "in-rhythm", "challenges", "on-the-road", "diy-drum-experiments", "rhythmic-adventures-of-captain-carson", "study-the-greats", "rhythms-from-another-planet", "tama-drums", "paiste-cymbals", "behind-the-scenes", "exploring-beats", "sonor-drums", "course", "play-along", "student-focus", "coach-stream", "learning-path-level", "unit", "quick-tips", "live", "question-and-answer", "student-review", "boot-camps", "song", "chords-and-scales", "pack", "podcasts", "workout", "challenge", "challenge-part"],
259
- 'pianote': ["student-review", "student-reviews", "question-and-answer", "course", "play-along", "student-focus", "coach-stream", "learning-path-level", "unit", "quick-tips", "live", "question-and-answer", "student-review", "boot-camps", "song", "chords-and-scales", "pack", "podcasts", "workout", "challenge", "challenge-part"],
260
- 'guitareo': ["student-review", "student-reviews", "question-and-answer", "archives", "recording", "course", "play-along", "student-focus", "coach-stream", "learning-path-level", "unit", "quick-tips", "live", "question-and-answer", "student-review", "boot-camps", "song", "chords-and-scales", "pack", "podcasts", "workout", "challenge", "challenge-part"],
261
- 'singeo': ["student-review", "student-reviews", "question-and-answer", "course", "play-along", "student-focus", "coach-stream", "learning-path-level", "unit", "quick-tips", "live", "question-and-answer", "student-review", "boot-camps", "song", "chords-and-scales", "pack", "podcasts", "workout", "challenge", "challenge-part"],
262
- 'default': ["student-review", "student-reviews", "question-and-answer", "course", "play-along", "student-focus", "coach-stream", "learning-path-level", "unit", "quick-tips", "live", "question-and-answer", "student-review", "boot-camps", "song", "chords-and-scales", "pack", "podcasts", "workout", "challenge", "challenge-part"]
263
- };
264
- const typesString = arrayJoinWithQuotes(newTypes[brand] ?? newTypes['default']);
259
+ const newTypes = getNewReleasesTypes(brand);
260
+ const typesString = arrayJoinWithQuotes(newTypes);
265
261
  const start = (page - 1) * limit;
266
262
  const end = start + limit;
267
263
  const sortOrder = getSortOrder(sort);
@@ -297,15 +293,8 @@ export async function fetchNewReleases(brand, { page = 1, limit = 10, sort="-pub
297
293
  * .catch(error => console.error(error));
298
294
  */
299
295
  export async function fetchUpcomingEvents(brand, { page = 1, limit = 10 } = {}) {
300
- const baseLiveTypes = ["student-review", "student-reviews", "student-focus", "coach-stream", "live", "question-and-answer", "student-review", "boot-camps", "recording", "pack-bundle-lesson"];
301
- const liveTypes = {
302
- 'drumeo': [...baseLiveTypes, "drum-fest-international-2022", "spotlight", "the-history-of-electronic-drums", "backstage-secrets", "quick-tips", "student-collaborations", "live-streams", "podcasts", "solos", "gear-guides", "performances", "in-rhythm", "challenges", "on-the-road", "diy-drum-experiments", "rhythmic-adventures-of-captain-carson", "study-the-greats", "rhythms-from-another-planet", "tama-drums", "paiste-cymbals", "behind-the-scenes", "exploring-beats", "sonor-drums"],
303
- 'pianote': baseLiveTypes,
304
- 'guitareo': [...baseLiveTypes, "archives"],
305
- 'singeo': baseLiveTypes,
306
- 'default': baseLiveTypes
307
- };
308
- const typesString = arrayJoinWithQuotes(liveTypes[brand] ?? liveTypes['default']);
296
+ const liveTypes = getUpcomingEventsTypes(brand);
297
+ const typesString = arrayJoinWithQuotes(liveTypes);
309
298
  const now = getSanityDate(new Date());
310
299
  const start = (page - 1) * limit;
311
300
  const end = start + limit;
@@ -325,6 +314,45 @@ export async function fetchUpcomingEvents(brand, { page = 1, limit = 10 } = {})
325
314
  return fetchSanity(query, true);
326
315
  }
327
316
 
317
+ /**
318
+ * Fetch scheduled releases for a specific brand.
319
+ *
320
+ * @param {string} brand - The brand for which to fetch scheduled releasess.
321
+ * @returns {Promise<Object|null>} - A promise that resolves to an array of scheduled release objects or null if not found.
322
+ *
323
+ * @example
324
+ * fetchScheduledReleases('drumeo', {
325
+ * page: 2,
326
+ * limit: 20,
327
+ * })
328
+ * .then(content => console.log(content))
329
+ * .catch(error => console.error(error));
330
+ */
331
+ export async function fetchScheduledReleases(brand, { page = 1, limit = 10 }) {
332
+ const upcomingTypes = getUpcomingEventsTypes(brand);
333
+ const newTypes = getNewReleasesTypes(brand);
334
+
335
+ const scheduledTypes = merge(upcomingTypes, newTypes)
336
+ const typesString = arrayJoinWithQuotes(scheduledTypes);
337
+ const now = getSanityDate(new Date());
338
+ const start = (page - 1) * limit;
339
+ const end = start + limit;
340
+ const query = `*[_type in [${typesString}] && brand == '${brand}' && status in ['published','scheduled'] && published_on > '${now}']{
341
+ "id": railcontent_id,
342
+ title,
343
+ "image": thumbnail.asset->url,
344
+ "artist_name": instructor[0]->name,
345
+ "artists": instructor[]->name,
346
+ difficulty,
347
+ difficulty_string,
348
+ length_in_seconds,
349
+ published_on,
350
+ "type": _type,
351
+ web_url_path,
352
+ } | order(published_on asc)[${start}...${end}]`;
353
+ return fetchSanity(query, true);
354
+ }
355
+
328
356
  /**
329
357
  * Fetch content by a specific Railcontent ID.
330
358
  *
@@ -415,7 +443,7 @@ export async function fetchAll(brand, type, {
415
443
  const searchFilter = searchTerm
416
444
  ? groupBy !== "" ?
417
445
  `&& (^.name match "${searchTerm}*" || title match "${searchTerm}*")`
418
- : `&& (artist->name match "${searchTerm}*" || instructor[]->name match "${searchTerm}*" || title match "${searchTerm}*")`
446
+ : `&& (artist->name match "${searchTerm}*" || instructor[]->name match "${searchTerm}*" || title match "${searchTerm}*" || name match "${searchTerm}*")`
419
447
  : "";
420
448
 
421
449
  // Construct the included fields filter, replacing 'difficulty' with 'difficulty_string'
@@ -499,8 +527,9 @@ export function getSortOrder(sort= '-published_on', groupBy)
499
527
  case "slug":
500
528
  sortOrder = groupBy ? 'name' : "title";
501
529
  break;
530
+ case "name":
502
531
  case "popularity":
503
- sortOrder = "popularity";
532
+ sortOrder = sort;
504
533
  break;
505
534
  case "published_on":
506
535
  default:
@@ -664,6 +693,7 @@ export async function fetchMethod(brand, slug) {
664
693
  "type": _type,
665
694
  "description": ${descriptionField},
666
695
  "url": web_url_path,
696
+ web_url_path,
667
697
  xp,
668
698
  }
669
699
  } | order(published_on asc)`
@@ -821,7 +851,13 @@ export async function fetchLessonContent(railContentId) {
821
851
  "coach_profile_image":thumbnail_url.asset->url
822
852
  },
823
853
  "instructors":instructor[]->name,
824
- instructor[]->,
854
+ "instructor": instructor[]->{
855
+ "id":_id,
856
+ name,
857
+ short_bio,
858
+ web_url_path,
859
+ "coach_card_image": coach_card_image.asset->url,
860
+ },
825
861
  ${assignmentsField}
826
862
  video,
827
863
  length_in_seconds
@@ -1052,6 +1088,89 @@ export async function fetchByReference(brand, {
1052
1088
  return fetchSanity(query, true);
1053
1089
  }
1054
1090
 
1091
+ /**
1092
+ * Fetch the artist's lessons.
1093
+ * @param {string} brand - The brand for which to fetch lessons.
1094
+ * @param {string} name - The name of the artist
1095
+ * @param {string} contentType - The type of the lessons we need to get from the artist. If not defined, groq will get lessons from all content types
1096
+ * @returns {Promise<Object|null>} - The lessons for the artist and some details about the artist (name and thumbnail).
1097
+ *
1098
+ * @example
1099
+ * fetchArtistLessons('10 Years', 'song')
1100
+ * .then(lessons => console.log(lessons))
1101
+ * .catch(error => console.error(error));
1102
+ */
1103
+ export async function fetchArtistLessons(brand, name, contentType, {
1104
+ sort = '-published_on',
1105
+ searchTerm = '',
1106
+ page = 1,
1107
+ limit = 10,
1108
+ includedFields = [],
1109
+ } = {}) {
1110
+ const fieldsString = DEFAULT_FIELDS.join(',');
1111
+ const start = (page - 1) * limit;
1112
+ const end = start + limit;
1113
+ const searchFilter = searchTerm ? `&& title match "${searchTerm}*"`: ''
1114
+ const sortOrder = getSortOrder(sort);
1115
+ const addType = contentType ? `_type == '${contentType}' && `:''
1116
+ const includedFieldsFilter = includedFields.length > 0
1117
+ ? filtersToGroq(includedFields)
1118
+ : "";
1119
+
1120
+ const query = `{
1121
+ "entity":
1122
+ *[_type == 'artist' && name == '${name}']
1123
+ {'type': _type, name, 'thumbnail_url':thumbnail_url.asset->url,
1124
+ 'lessons_count': count(*[${addType} brand == '${brand}' && references(^._id)]),
1125
+ 'lessons': *[${addType} brand == '${brand}' && references(^._id) ${searchFilter} ${includedFieldsFilter}]{${fieldsString}}
1126
+ [${start}...${end}]}
1127
+ |order(${sortOrder})
1128
+ }`;
1129
+ return fetchSanity(query, true);
1130
+ }
1131
+
1132
+ /**
1133
+ * Fetch the genre's lessons.
1134
+ * @param {string} brand - The brand for which to fetch lessons.
1135
+ * @param {string} name - The name of the genre
1136
+ * @param {string} contentType - The type of the lessons we need to get from the genre. If not defined, groq will get lessons from all content types
1137
+ * @returns {Promise<Object|null>} - The lessons for the genre and some details about the genre (name and thumbnail).
1138
+ *
1139
+ * @example
1140
+ * fetchGenreLessons('Blues', 'song')
1141
+ * .then(lessons => console.log(lessons))
1142
+ * .catch(error => console.error(error));
1143
+ */
1144
+ export async function fetchGenreLessons(brand, name, contentType, {
1145
+ sort = '-published_on',
1146
+ searchTerm = '',
1147
+ page = 1,
1148
+ limit = 10,
1149
+ includedFields = [],
1150
+ } = {}) {
1151
+ const fieldsString = DEFAULT_FIELDS.join(',');
1152
+ const start = (page - 1) * limit;
1153
+ const end = start + limit;
1154
+ const searchFilter = searchTerm ? `&& title match "${searchTerm}*"`: ''
1155
+ const sortOrder = getSortOrder(sort);
1156
+ const addType = contentType ? `_type == '${contentType}' && `:''
1157
+ const includedFieldsFilter = includedFields.length > 0
1158
+ ? filtersToGroq(includedFields)
1159
+ : "";
1160
+
1161
+ const query = `{
1162
+ "entity":
1163
+ *[_type == 'genre' && name == '${name}']
1164
+ {'type': _type, name, 'thumbnail_url':thumbnail_url.asset->url,
1165
+ 'lessons_count': count(*[${addType} brand == '${brand}' && references(^._id)]),
1166
+ 'lessons': *[${addType} brand == '${brand}' && references(^._id) ${searchFilter} ${includedFieldsFilter}]{${fieldsString}}
1167
+ [${start}...${end}]}
1168
+ |order(${sortOrder})
1169
+ }`;
1170
+ return fetchSanity(query, true);
1171
+ }
1172
+
1173
+
1055
1174
 
1056
1175
  /**
1057
1176
  * Fetch data from the Sanity API based on a provided query.
@@ -1141,6 +1260,13 @@ function getSanityDate(date) {
1141
1260
  return date.toISOString();
1142
1261
  }
1143
1262
 
1263
+ const merge = (a, b, predicate = (a, b) => a === b) => {
1264
+ const c = [...a]; // copy to avoid side effects
1265
+ // add all items from B to copy C if they're not already present
1266
+ b.forEach((bItem) => (c.some((cItem) => predicate(bItem, cItem)) ? null : c.push(bItem)))
1267
+ return c;
1268
+ }
1269
+
1144
1270
  function checkSanityConfig(config) {
1145
1271
  if (!config.sanityConfig.token) {
1146
1272
  console.warn('fetchSanity: The "token" property is missing in the config object.');
@@ -0,0 +1,105 @@
1
+ import {fetchUserContext, fetchLikeContent, fetchUnlikeContent} from "./railcontent";
2
+
3
+ const StorageKey = "userContext";
4
+ let userContext = null;
5
+ let cache = null;
6
+
7
+ export function init(localCache) {
8
+ cache = localCache;
9
+ }
10
+
11
+ export function version() {
12
+ ensureLocalContextLoaded();
13
+ return userContext.version;
14
+ }
15
+
16
+ async function getUserContext() {
17
+ ensureLocalContextLoaded();
18
+ if (userContext) {
19
+ verifyContextIsValid();
20
+ }
21
+ if (!userContext) {
22
+ await fetchFromServer();
23
+ }
24
+ return userContext;
25
+ }
26
+
27
+ function verifyContextIsValid() {
28
+
29
+ }
30
+
31
+ function ensureLocalContextLoaded() {
32
+ if (userContext) return;
33
+ let localData = cache.getItem(StorageKey);
34
+ if (localData) {
35
+ userContext = JSON.parse(localData);
36
+ }
37
+ }
38
+
39
+ function updateLocalContext(contentId, updateFunction) {
40
+ ensureLocalContextLoaded();
41
+ if (userContext) {
42
+ let contentData = userContext.data[contentId] ?? [];
43
+ updateFunction(contentData);
44
+ userContext.data[contentId] = contentData;
45
+ userContext.version++;
46
+ let data = JSON.stringify(userContext);
47
+ cache.setItem(StorageKey, data);
48
+ }
49
+ }
50
+
51
+ async function fetchFromServer() {
52
+ let data = await fetchUserContext();
53
+ userContext = JSON.parse(data);
54
+ cache.setItem(StorageKey, data);
55
+ }
56
+
57
+
58
+ function transformData(data, contentId) {
59
+ let transformed = [];
60
+ transformed["contentId"] = contentId;
61
+ transformed["liked"] = (data && data.l) ?? 0;
62
+ return transformed;
63
+ }
64
+
65
+ export async function fetchContentData(contentId) {
66
+ let userContext = await getUserContext();
67
+ let data = userContext.data[contentId];
68
+ data = transformData(data, contentId);
69
+ return data;
70
+ }
71
+
72
+ export function clearCache() {
73
+ userContext = null;
74
+ cache.setItem(StorageKey, null);
75
+ }
76
+
77
+ export function testClearLocal() {
78
+ userContext = null;
79
+ }
80
+
81
+ export async function likeContent(contentId) {
82
+ updateLocalContext(contentId,
83
+ function (contentData) {
84
+ contentData.l = 1;
85
+ }
86
+ );
87
+
88
+ let result = await fetchLikeContent(contentId);
89
+ if (result.version !== userContext.version) {
90
+ clearCache();
91
+ }
92
+ }
93
+
94
+ export async function unlikeContent(contentId) {
95
+ updateLocalContext(contentId,
96
+ function (contentData) {
97
+ contentData.l = 0;
98
+ }
99
+ );
100
+
101
+ let result = await fetchUnlikeContent(contentId);
102
+ if (result.version !== userContext.version) {
103
+ clearCache();
104
+ }
105
+ }
@@ -0,0 +1,21 @@
1
+ export class LocalStorageMock {
2
+ constructor() {
3
+ this.store = {};
4
+ }
5
+
6
+ clear() {
7
+ this.store = {};
8
+ }
9
+
10
+ getItem(key) {
11
+ return this.store[key] || null;
12
+ }
13
+
14
+ setItem(key, value) {
15
+ this.store[key] = String(value);
16
+ }
17
+
18
+ removeItem(key) {
19
+ delete this.store[key];
20
+ }
21
+ }
@@ -32,6 +32,7 @@ const {
32
32
  fetchPacksAll,
33
33
  fetchCoachLessons,
34
34
  fetchByReference,
35
+ fetchScheduledReleases
35
36
  } = require('../src/services/sanity.js');
36
37
 
37
38
  describe('Sanity Queries', function () {
@@ -286,4 +287,9 @@ describe('Sanity Queries', function () {
286
287
  const response = await fetchByReference('drumeo', { includedFields: ['is_featured'] });
287
288
  expect(response.entity.length).toBeGreaterThan(0);
288
289
  });
290
+
291
+ test('fetchScheduledReleases', async () => {
292
+ const response = await fetchScheduledReleases('drumeo', {});
293
+ expect(response.length).toBeGreaterThan(0);
294
+ });
289
295
  });
@@ -0,0 +1,86 @@
1
+ const railContentModule = require('../src/services/railcontent.js')
2
+ const userContextModule = require('../src/services/userContext.js');
3
+ import {LocalStorageMock} from "./localStorageMock";
4
+
5
+ describe('userContext', function () {
6
+ let mock = null;
7
+ const testVersion = 1;
8
+ beforeEach(() => {
9
+ userContextModule.init(new LocalStorageMock());
10
+ mock = jest.spyOn(railContentModule, 'fetchUserContext');
11
+ var json = `{"version":${testVersion},"data":{"308516":{"l":1},"308515":{"p":100},"308514":{"p":13},"308518":{"p":100}}}`;
12
+ mock.mockImplementation(() => json);
13
+ });
14
+
15
+ test('contentLiked', async () => {
16
+ let contentData = await userContextModule.fetchContentData(308516);
17
+ expect(contentData.liked).toBe(1);
18
+ });
19
+
20
+ test('contentDoesNotExist', async () => {
21
+ //fetch content that does not exist
22
+ let contentData = await userContextModule.fetchContentData(121111);
23
+ expect(contentData.liked).toBe(0);
24
+ });
25
+
26
+ test('ensureOnlyOneServerFetchRequest', async () => {
27
+ userContextModule.clearCache();
28
+ await userContextModule.fetchContentData(308516);
29
+ await userContextModule.fetchContentData(308514);
30
+ expect(railContentModule.fetchUserContext).toHaveBeenCalledTimes(1);
31
+ });
32
+
33
+ test('ensureDataPulledFromLocalCache', async () => {
34
+ userContextModule.clearCache();
35
+ await userContextModule.fetchContentData(308516);
36
+ userContextModule.testClearLocal();
37
+ await userContextModule.fetchContentData(308514);
38
+ expect(railContentModule.fetchUserContext).toHaveBeenCalledTimes(1);
39
+ });
40
+
41
+ // test('hashExpiration', async () => {
42
+ // userContextModule.clearCache();
43
+ // await userContextModule.fetchContentData(testHash308516);
44
+ // let newHash = "8g9qg5wn3e5s5oi69q6g22et9w6g34t5";
45
+ // await userContextModule.fetchContentData(newHash, 308516);
46
+ // expect(railContentModule.fetchUserContext).toHaveBeenCalledTimes(2);
47
+ // });
48
+
49
+ test('likeContent', async () => {
50
+ mock = jest.spyOn(railContentModule, 'fetchLikeContent');
51
+ var json = JSON.parse(`{"version":${testVersion + 1}}`);
52
+ mock.mockImplementation(() => json);
53
+
54
+ userContextModule.clearCache();
55
+ await userContextModule.fetchContentData(308515);
56
+ await userContextModule.likeContent(308515);
57
+ let contentData = await userContextModule.fetchContentData(308515);
58
+ expect(contentData.liked).toBe(1);
59
+
60
+ userContextModule.testClearLocal();
61
+ contentData = await userContextModule.fetchContentData(308515);
62
+ expect(contentData.liked).toBe(1);
63
+
64
+ expect(userContextModule.version()).toBe(testVersion + 1);
65
+ });
66
+
67
+
68
+ test('unlikeContent', async () => {
69
+ mock = jest.spyOn(railContentModule, 'fetchUnlikeContent');
70
+ var json = JSON.parse(`{"version":${testVersion + 1}}`);
71
+ mock.mockImplementation(() => json);
72
+
73
+ userContextModule.clearCache();
74
+ await userContextModule.fetchContentData(308516);
75
+ await userContextModule.unlikeContent(308516);
76
+ let contentData = await userContextModule.fetchContentData(308516);
77
+ expect(contentData.liked).toBe(0);
78
+
79
+ userContextModule.testClearLocal();
80
+ contentData = await userContextModule.fetchContentData(308516);
81
+ expect(contentData.liked).toBe(0);
82
+
83
+ expect(userContextModule.version()).toBe(testVersion + 1);
84
+ });
85
+
86
+ });