musora-content-services 2.178.0 → 2.179.0

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.
Files changed (39) hide show
  1. package/.agent/decisions/2026-05-20-content-progress-ts-part-5.md +39 -0
  2. package/.agent/decisions/2026-08-28-mu2-1877-marketing-sanity-queries.md +38 -0
  3. package/.claude/settings.local.json +71 -0
  4. package/CHANGELOG.md +14 -0
  5. package/package.json +2 -1
  6. package/src/index.d.ts +13 -0
  7. package/src/index.js +13 -0
  8. package/src/lib/ads/coproduct.ts +57 -10
  9. package/src/lib/ads/either.ts +11 -1
  10. package/src/lib/sanity/types/marketing.d.ts +94 -0
  11. package/src/services/marketing/marketing.ts +87 -0
  12. package/src/services/progress/collections.ts +8 -0
  13. package/src/services/progress/index.ts +2 -1
  14. package/src/services/progress/internal/bubble.ts +4 -1
  15. package/src/services/progress/internal/learning-path.ts +14 -0
  16. package/src/services/progress/internal/queries.ts +7 -3
  17. package/src/services/progress/mutations.ts +316 -0
  18. package/src/services/progress/state.ts +54 -33
  19. package/src/services/sanity.js +2 -2
  20. package/src/services/user/memberships.ts +1 -0
  21. package/src/version-info.js +1 -1
  22. package/tools/generate-sanity-types.cjs +212 -0
  23. package/.idea/codeStyles/Project.xml +0 -61
  24. package/.idea/codeStyles/codeStyleConfig.xml +0 -5
  25. package/.idea/dataSources/6549dc28-6466-4ea3-a66b-7750cfeae4e7/storage_v2/_src_/schema/information_schema.FNRwLQ.meta +0 -2
  26. package/.idea/dataSources/6549dc28-6466-4ea3-a66b-7750cfeae4e7/storage_v2/_src_/schema/mysql.osA4Bg.meta +0 -2
  27. package/.idea/dataSources/6549dc28-6466-4ea3-a66b-7750cfeae4e7/storage_v2/_src_/schema/performance_schema.kIw0nw.meta +0 -2
  28. package/.idea/dataSources/6549dc28-6466-4ea3-a66b-7750cfeae4e7/storage_v2/_src_/schema/sys.zb4BAA.meta +0 -2
  29. package/.idea/dataSources/6549dc28-6466-4ea3-a66b-7750cfeae4e7.xml +0 -25767
  30. package/.idea/dataSources/data_sources_history.xml +0 -23
  31. package/.idea/dataSources.local.xml +0 -19
  32. package/.idea/dataSources.xml +0 -12
  33. package/.idea/modules.xml +0 -8
  34. package/.idea/musora-content-services.iml +0 -8
  35. package/.idea/php.xml +0 -19
  36. package/.idea/prettier.xml +0 -6
  37. package/.idea/vcs.xml +0 -6
  38. package/.idea/workspace.xml +0 -349
  39. package/.yarn/install-state.gz +0 -0
@@ -0,0 +1,39 @@
1
+ ---
2
+ date: 2026-05-20
3
+ branch: refactor/content-progress-ts-part-5
4
+ pr: https://github.com/railroadmedia/musora-content-services/pull/981
5
+ status: open
6
+ tags: [[chore]]
7
+ related: [[2026-05-19-content-progress-ts-part-2]]
8
+ ---
9
+
10
+ # Extract progress mutations and learning-path duplication guard
11
+
12
+ ## Context
13
+
14
+ With the read path (`state.ts`, `collections.ts`), shared types, bubble/trickle logic (`internal/bubble.ts`), and query helpers (`internal/queries.ts`) already extracted in earlier parts of the refactor, the write path remained in the legacy `contentProgress.js`. All mutation operations (`save`, `setStatus`, `setStatusMany`, `markCompleted`, `markStarted`, `reset`) needed to move into the typed `progress/` module to complete the decomposition and enable direct test coverage.
15
+
16
+ ## Decision
17
+
18
+ Introduced two new files:
19
+
20
+ - `src/services/progress/mutations.ts` — owns all write operations. `save` handles offline/online paths, filters negative-progress writes, computes bubble/trickle, and delegates LP completion actions. `setStatus`/`setStatusMany`/`reset` follow the same shape. Public `mark*` aliases provide a stable call surface. A private `requestPush` helper centralises the skipPush guard pattern.
21
+ - `src/services/progress/internal/learning-path.ts` — extracts `filterOutLearningPathsForDuplication`, the rule that prevents the LP root record from being written back as an a-la-carte record. Isolating it makes the invariant visible and independently testable.
22
+
23
+ Test coverage added:
24
+ - `test/integration/progress/mutations.test.ts` — integration tests against the in-memory WatermelonDB harness covering all public mutation functions and key scenarios (higher/lower/equal progress, skipBubbleTrickle, etc.)
25
+ - `test/unit/services/progress-internal/learning-path.test.ts` — unit tests for the LP duplication filter covering non-LP passthrough, LP root exclusion, and string-key coercion
26
+
27
+ ## Alternatives Considered
28
+
29
+ Keeping mutations in `contentProgress.js` until the full JS→TS migration is complete was considered but rejected. The write path is high-risk and untested; extracting it into TypeScript now lets us add direct coverage and enforce types on the critical save/bubble interaction.
30
+
31
+ ## Process Notes
32
+
33
+ `filterOutLearningPathsForDuplication` uses `Number(id)` on string keys from `Object.entries` to match the numeric `collection.id`. A dedicated unit test documents and guards this coercion.
34
+
35
+ ## Consequences
36
+
37
+ - `Progress.*` namespace now covers both the read and write path; callers can import from `src/services/progress` for all operations
38
+ - The write path has direct integration test coverage for the first time
39
+ - `contentProgress.js` mutation functions can be deprecated and removed in a follow-up once consumers are migrated
@@ -0,0 +1,38 @@
1
+ ---
2
+ date: 2026-08-28
3
+ branch: feat/marketing-sanity-queries
4
+ pr: https://github.com/railroadmedia/musora-content-services/pull/1040
5
+ status: open
6
+ tags: [[feature]]
7
+ related: [[2026-07-20-query-builder-tostring]]
8
+ ---
9
+
10
+ # Marketing workspace Sanity types and query functions
11
+
12
+ ## Context
13
+ The new marketing workspace in Sanity introduced four document types — `stats`, `practice-goal`, `testimonial` and `faq` — with no typed access from MCS. Consumers would otherwise hand-write both the GROQ and the result shapes, which drift as the schema changes.
14
+
15
+ ## Decision
16
+ Two pieces:
17
+
18
+ - `tools/generate-sanity-types.cjs` (exposed as `npm run gen-sanity-types`) derives TypeScript declarations from the musora-platform-backend Sanity schema into `src/lib/sanity/types/marketing.d.ts`. Generated, not hand-maintained, so schema changes are picked up by re-running the generator. Covered by `test/unit/generateSanityTypes.test.js`.
19
+ - `src/services/marketing/marketing.ts` exposes `fetchMarketingStats`, `fetchMarketingPracticeGoals`, `fetchMarketingTestimonials` and `fetchMarketingFaqs`, built on the newer `groq`/`query`/`filter` helpers in `src/lib/sanity/` rather than the legacy query strings in `sanity.js`.
20
+
21
+ All four documents are singletons per brand, so a single private `fetchBrandDocument<T>(type, brand)` helper applies `Filters.combine(Filters.type(type), Filters.brand(brand))`, takes `.first()`, and collapses the `Either` with `.recover(null)`. Each public function is a one-line wrapper that pins the generated type. `fetchMarketingFaqs` additionally takes `includeWebOnly = true`; when false it filters out questions flagged `web_only`, for mobile callers.
22
+
23
+ No field projections — the queries return the whole document, which is exactly what the generated types describe.
24
+
25
+ ## Alternatives Considered
26
+ - One file per document type under `src/services/marketing/`. Rejected: four near-identical three-line functions, single file is smaller and easier to read.
27
+ - Explicit `select(...)` projections per document. Rejected: these documents are small and the generated type already covers every field; projections would need updating on every schema change for no measurable gain.
28
+ - Hand-written type declarations. Rejected: guaranteed to drift from the backend schema.
29
+
30
+ ## Process Notes
31
+ - The generated `StatsDocument` is a discriminated union on `brand` — `musora` carries `total_student_count`, the instrument brands carry `learning_paths_count` and `artist_courses_count`. Callers must narrow on `brand` before reaching for those fields.
32
+ - `run<T>()` already returns `T | null` for a query that matched nothing; `.recover(null)` only handles the `Left` (query error) case.
33
+ - `src/index.js` / `src/index.d.ts` are generated by `npm run build-index` and were regenerated in this branch to export the four new functions.
34
+
35
+ ## Consequences
36
+ - Marketing document access is typed and centralised; new marketing document types follow the same pattern.
37
+ - The generated types must be refreshed with `npm run gen-sanity-types` whenever the backend marketing schema changes — a stale `marketing.d.ts` will typecheck fine while being wrong.
38
+ - No new runtime dependencies.
@@ -0,0 +1,71 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(npx jest *)",
5
+ "Bash(npx tsc *)",
6
+ "Skill(counselors)",
7
+ "Bash(counselors ls *)",
8
+ "Bash(counselors groups *)",
9
+ "Bash(counselors run *)",
10
+ "Bash(npm test *)",
11
+ "Bash(gh pr *)",
12
+ "Bash(gh api *)",
13
+ "Bash(mkdir -p /tmp/pr-review-v2)",
14
+ "Read(//tmp/pr-review-v2/**)",
15
+ "Bash(cat /home/alesevero/railenvironment/applications/musora-content-services/AGENTS.md)",
16
+ "Bash(echo \"no AGENTS.md\")",
17
+ "Bash(echo \"exit=$?\")",
18
+ "Bash(git checkout *)",
19
+ "Skill(pr)",
20
+ "Skill(create-decision)",
21
+ "mcp__github__pull_request_read",
22
+ "mcp__github__push_files",
23
+ "Bash(git push *)",
24
+ "mcp__github__create_pull_request",
25
+ "mcp__github__search_pull_requests",
26
+ "Bash(git commit *)",
27
+ "Bash(git add *)",
28
+ "mcp__github__list_pull_requests",
29
+ "mcp__sanity__get_schema",
30
+ "mcp__sanity__query_documents",
31
+ "Bash(git rm *)",
32
+ "Bash(git rebase *)",
33
+ "Bash(git fetch *)",
34
+ "Bash(git --no-pager log --oneline -1)",
35
+ "Bash(git --no-pager log --oneline -5)",
36
+ "Bash(git ls-tree *)",
37
+ "Bash(git --no-pager log --oneline -2)",
38
+ "Bash(git --no-pager diff --stat HEAD~1)",
39
+ "Bash(git check-ignore *)",
40
+ "Bash(python3 -)",
41
+ "Bash(git --no-pager log --oneline -3)",
42
+ "Bash(mkdir *)",
43
+ "Bash(git --no-pager log --oneline origin/main..HEAD)"
44
+ ]
45
+ },
46
+ "model": "sonnet",
47
+ "enabledMcpjsonServers": [
48
+ "atlassian",
49
+ "figma",
50
+ "google-workspace",
51
+ "snowflake",
52
+ "aws",
53
+ "hex",
54
+ "sanity",
55
+ "mysql",
56
+ "slack",
57
+ "langfuse",
58
+ "chrome-devtools",
59
+ "railway",
60
+ "github",
61
+ "asana"
62
+ ],
63
+ "disabledMcpjsonServers": [
64
+ "nightwatch",
65
+ "1password",
66
+ "mysql-staging",
67
+ "mysql-local",
68
+ "mobile"
69
+ ],
70
+ "effortLevel": "medium"
71
+ }
package/CHANGELOG.md CHANGED
@@ -2,6 +2,20 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
4
4
 
5
+ ## [2.179.0](https://github.com/railroadmedia/musora-content-services/compare/v2.178.1...v2.179.0) (2026-09-03)
6
+
7
+
8
+ ### Features
9
+
10
+ * **MU2-1877:** Add Sanity type generation and marketing query functions ([#1040](https://github.com/railroadmedia/musora-content-services/issues/1040)) ([a232299](https://github.com/railroadmedia/musora-content-services/commit/a232299286ce22fa04591a812c65039544682e37))
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * **BR-725:** fetch tab data with progress applied ([#1044](https://github.com/railroadmedia/musora-content-services/issues/1044)) ([d19baf1](https://github.com/railroadmedia/musora-content-services/commit/d19baf1565d54a5e6179b6ae21b184ceb64c8aee))
16
+
17
+ ### [2.178.1](https://github.com/railroadmedia/musora-content-services/compare/v2.178.0...v2.178.1) (2026-08-31)
18
+
5
19
  ## [2.178.0](https://github.com/railroadmedia/musora-content-services/compare/v2.177.1...v2.178.0) (2026-08-28)
6
20
 
7
21
 
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "musora-content-services",
3
- "version": "2.178.0",
3
+ "version": "2.179.0",
4
4
  "description": "A package for Musoras content services ",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
7
7
  "scripts": {
8
8
  "prepare": "node scripts/gen-version.cjs",
9
9
  "build-index": "node tools/generate-index.cjs",
10
+ "gen-sanity-types": "node tools/generate-sanity-types.cjs",
10
11
  "release": "standard-version",
11
12
  "doc": "jsdoc -c jsdoc.json --verbose",
12
13
  "test": "jest",
package/src/index.d.ts CHANGED
@@ -224,6 +224,14 @@ import {
224
224
  createTestUser
225
225
  } from './services/liveTesting.ts';
226
226
 
227
+ import {
228
+ fetchMarketingAll,
229
+ fetchMarketingFaqs,
230
+ fetchMarketingPracticeGoals,
231
+ fetchMarketingStats,
232
+ fetchMarketingTestimonials
233
+ } from './services/marketing/marketing.ts';
234
+
227
235
  import {
228
236
  acceptInvite,
229
237
  createAccount,
@@ -654,6 +662,11 @@ declare module 'musora-content-services' {
654
662
  fetchLiveEvent,
655
663
  fetchLiveEventPollingState,
656
664
  fetchLiveStreamData,
665
+ fetchMarketingAll,
666
+ fetchMarketingFaqs,
667
+ fetchMarketingPracticeGoals,
668
+ fetchMarketingStats,
669
+ fetchMarketingTestimonials,
657
670
  fetchMemberships,
658
671
  fetchMetadata,
659
672
  fetchMethodV2IntroVideo,
package/src/index.js CHANGED
@@ -228,6 +228,14 @@ import {
228
228
  createTestUser
229
229
  } from './services/liveTesting.ts';
230
230
 
231
+ import {
232
+ fetchMarketingAll,
233
+ fetchMarketingFaqs,
234
+ fetchMarketingPracticeGoals,
235
+ fetchMarketingStats,
236
+ fetchMarketingTestimonials
237
+ } from './services/marketing/marketing.ts';
238
+
231
239
  import {
232
240
  acceptInvite,
233
241
  createAccount,
@@ -653,6 +661,11 @@ export {
653
661
  fetchLiveEvent,
654
662
  fetchLiveEventPollingState,
655
663
  fetchLiveStreamData,
664
+ fetchMarketingAll,
665
+ fetchMarketingFaqs,
666
+ fetchMarketingPracticeGoals,
667
+ fetchMarketingStats,
668
+ fetchMarketingTestimonials,
656
669
  fetchMemberships,
657
670
  fetchMetadata,
658
671
  fetchMethodV2IntroVideo,
@@ -4,7 +4,14 @@ import { Recoverable } from './interfaces/recoverable'
4
4
  import { Tappable } from './interfaces/tappable'
5
5
  import { Monad } from './monad'
6
6
 
7
- /** A monadic container that represents a value that can be either a left or a right value. */
7
+ /**
8
+ * A monadic container holding either a left value (`L`, usually an error/failure) or a right
9
+ * value (`R`, usually a success). Convention: right is the "happy path" that `map`/`flatMap`
10
+ * operate on; left short-circuits those and passes through unchanged.
11
+ * @example
12
+ * Coproduct.right(5).map(n => n + 1) // Right(6)
13
+ * Coproduct.left('err').map((n: number) => n) // Left('err'), fn never called
14
+ */
8
15
  export abstract class Coproduct<L, R>
9
16
  implements
10
17
  Tappable<L | R>,
@@ -13,40 +20,71 @@ export abstract class Coproduct<L, R>
13
20
  Recoverable<R>,
14
21
  Monad<L | R>
15
22
  {
23
+ /** @param value - the left value to wrap */
16
24
  static left<L, R>(value: L): Coproduct<L, R> {
17
25
  return new Left(value)
18
26
  }
19
27
 
28
+ /** @param value - the right value to wrap */
20
29
  static right<L, R>(value: R): Coproduct<L, R> {
21
30
  return new Right(value)
22
31
  }
23
32
 
33
+ /** Type guard: true if this is a {@link Left}. */
24
34
  abstract isLeft(): this is Left<L, R>
35
+ /** Type guard: true if this is a {@link Right}. */
25
36
  abstract isRight(): this is Right<L, R>
26
37
 
27
38
  /**
28
39
  * @extends Functor
29
40
  * Maps the right value of the Coproduct.
41
+ * @example
42
+ * Coproduct.right(2).map(n => n * 2) // Right(4)
43
+ * Coproduct.left('err').map(n => n) // Left('err'), fn never called
30
44
  */
31
45
  abstract map<T>(fn: (r: R) => T): Coproduct<L, T>
46
+
32
47
  /**
33
48
  * Maps the right value of the Coproduct with an asynchronous function.
34
49
  * A rejection propagates: there is no way to build an L from it.
50
+ * @example
51
+ * await Coproduct.right(userId).mapAsync(id => fetchUser(id)) // Right(user)
35
52
  */
36
53
  abstract mapAsync<T>(fn: (r: R) => Promise<T>): Promise<Coproduct<L, T>>
54
+
37
55
  /**
38
56
  * @extends Monad
39
- * Applies a function to the right value of the Coproduct, returning a new Coproduct.
57
+ * Like {@link map}, but `fn` itself returns a Coproduct instead of a plain value, so nested
58
+ * Coproducts get flattened. No-op on Left.
59
+ * @example
60
+ * Coproduct.right(id).flatMap(id => validate(id)) // validate returns a Coproduct itself
40
61
  */
41
62
  abstract flatMap<T>(fn: (r: R) => Coproduct<L, T>): Coproduct<L, T>
42
63
 
43
- /** Maps the left value of the Coproduct to a new type. */
44
- abstract mapLeft<T>(fn: (l: L) => T): Coproduct<T, R>
64
+ /**
65
+ * Maps the left value of the Coproduct to a new type. No-op on Right.
66
+ * @example
67
+ * Coproduct.left(new Error('bad')).lmap(e => e.message) // Left('bad')
68
+ */
69
+ abstract lmap<T>(fn: (l: L) => T): Coproduct<T, R>
45
70
 
46
- /** Applies a function to the left value of the Coproduct, returning a new Coproduct. */
47
- abstract flatMapLeft<T>(fn: (l: L) => Coproduct<T, R>): Coproduct<T, R>
71
+ /**
72
+ * Left-side equivalent of {@link flatMap}: `fn` returns a Coproduct, flattening nested
73
+ * results. No-op on Right.
74
+ */
75
+ abstract lflatMap<T>(fn: (l: L) => Coproduct<T, R>): Coproduct<T, R>
48
76
 
77
+ /**
78
+ * Unwraps the Coproduct by calling whichever handler matches its side.
79
+ * @example
80
+ * result.fold(
81
+ * err => showError(err),
82
+ * value => showValue(value)
83
+ * )
84
+ */
49
85
  abstract fold<T, U>(onLeft: (l: L) => T, onRight: (r: R) => U): T | U
86
+
87
+ /** Convenience {@link fold} that applies the same `fn` regardless of side. */
50
88
  foldMap<T>(initial: T, fn: (acc: T, value: L | R) => T): T {
51
89
  return this.fold(
52
90
  (l) => fn(initial, l),
@@ -57,19 +95,28 @@ export abstract class Coproduct<L, R>
57
95
  /**
58
96
  * Visits the value inside the container if right and applies a function to it without modifying
59
97
  * @implements Tappable
98
+ * @example
99
+ * Coproduct.right(user).tap(u => console.log('loaded', u.id)) // logs, still Right(user)
60
100
  */
61
101
  abstract tap(fn: (r: R) => void): this
62
102
 
63
103
  /** Visits the value inside the container if left and applies a function to it without modifying */
64
104
  abstract ltap(fn: (l: L) => void): Coproduct<L, R>
105
+
106
+ /** Unwraps the Coproduct, returning whichever value it holds (left or right) untouched. */
65
107
  abstract drop(): R | L
108
+
66
109
  /**
67
110
  * Returns the right value if it exists, otherwise returns the provided default value.
68
111
  * @implements Recoverable
112
+ * @example
113
+ * Coproduct.left('err').recover(0) // 0
114
+ * Coproduct.right(5).recover(0) // 5
69
115
  */
70
116
  abstract recover(defaultValue: R): R
71
117
  }
72
118
 
119
+ /** A left value in a Coproduct, usually representing a failure or invalid result. */
73
120
  export class Left<L, R> extends Coproduct<L, R> {
74
121
  constructor(private readonly value: L) {
75
122
  super()
@@ -95,11 +142,11 @@ export class Left<L, R> extends Coproduct<L, R> {
95
142
  return new Left(this.value)
96
143
  }
97
144
 
98
- mapLeft<T>(fn: (l: L) => T): Coproduct<T, R> {
145
+ lmap<T>(fn: (l: L) => T): Coproduct<T, R> {
99
146
  return new Left(fn(this.value))
100
147
  }
101
148
 
102
- flatMapLeft<T>(fn: (l: L) => Coproduct<T, R>): Coproduct<T, R> {
149
+ lflatMap<T>(fn: (l: L) => Coproduct<T, R>): Coproduct<T, R> {
103
150
  return fn(this.value)
104
151
  }
105
152
 
@@ -152,11 +199,11 @@ export class Right<L, R> extends Coproduct<L, R> {
152
199
  return fn(this.value)
153
200
  }
154
201
 
155
- mapLeft<T>(_fn: (l: L) => T): Coproduct<T, R> {
202
+ lmap<T>(_fn: (l: L) => T): Coproduct<T, R> {
156
203
  return new Right(this.value)
157
204
  }
158
205
 
159
- flatMapLeft<T>(_fn: (l: L) => Coproduct<T, R>): Coproduct<T, R> {
206
+ lflatMap<T>(_fn: (l: L) => Coproduct<T, R>): Coproduct<T, R> {
160
207
  return new Right(this.value)
161
208
  }
162
209
 
@@ -1,6 +1,16 @@
1
1
  import { Coproduct } from './coproduct'
2
2
 
3
- /** A Coproduct under the convention that left carries the error and right carries the success value. */
3
+ /**
4
+ * A {@link Coproduct} under the convention that left carries the error and right carries the
5
+ * success value.
6
+ * @example
7
+ * function parseAge(input: string): Either<string, number> {
8
+ * const n = Number(input)
9
+ * return Number.isNaN(n) ? Either.left('not a number') : Either.right(n)
10
+ * }
11
+ * parseAge('42').map(n => n + 1) // Right(43)
12
+ * parseAge('x').map(n => n + 1) // Left('not a number')
13
+ */
4
14
  export type Either<L, R> = Coproduct<L, R>
5
15
 
6
16
  export const Either = {
@@ -0,0 +1,94 @@
1
+ // Generated by tools/generate-sanity-types.cjs from the musora-platform-backend Sanity schema.
2
+ // Do not edit by hand; re-run the generator instead.
3
+ export type StatsDocument = {
4
+ _id: string
5
+ _type: "stats"
6
+ _createdAt: string
7
+ _updatedAt: string
8
+ _rev: string
9
+ /** Songs Count */
10
+ songs_count?: string
11
+ } & (
12
+ | {
13
+ brand: "musora"
14
+ /** Total Student Count */
15
+ total_student_count?: string
16
+ }
17
+ | {
18
+ brand: "drumeo" | "pianote" | "singeo" | "guitareo" | "playbass"
19
+ /** Learning Paths Count */
20
+ learning_paths_count?: string
21
+ /** Artist Courses Count */
22
+ artist_courses_count?: string
23
+ }
24
+ )
25
+
26
+ export type PracticeGoalDocument = {
27
+ _id: string
28
+ _type: "practice-goal"
29
+ _createdAt: string
30
+ _updatedAt: string
31
+ _rev: string
32
+ brand: "drumeo" | "pianote" | "singeo" | "guitareo" | "playbass"
33
+ /** <2 Days a Week */
34
+ under_two_days_a_week?: {
35
+ /** First Day */
36
+ first_day?: string
37
+ /** First Week */
38
+ first_week?: string
39
+ /** First Month */
40
+ first_month?: string
41
+ }
42
+ /** 3-4 Days a Week */
43
+ three_to_four_days_a_week?: {
44
+ /** First Day */
45
+ first_day?: string
46
+ /** First Week */
47
+ first_week?: string
48
+ /** First Month */
49
+ first_month?: string
50
+ }
51
+ /** 5+ Days a Week */
52
+ five_plus_days_a_week?: {
53
+ /** First Day */
54
+ first_day?: string
55
+ /** First Week */
56
+ first_week?: string
57
+ /** First Month */
58
+ first_month?: string
59
+ }
60
+ }
61
+
62
+ export type TestimonialDocument = {
63
+ _id: string
64
+ _type: "testimonial"
65
+ _createdAt: string
66
+ _updatedAt: string
67
+ _rev: string
68
+ brand: "musora" | "drumeo" | "pianote" | "singeo" | "guitareo" | "playbass"
69
+ /** Testimonials */
70
+ testimonials?: {
71
+ _key: string
72
+ name?: string
73
+ location?: string
74
+ /** Testimonial Copy */
75
+ copy?: string
76
+ }[]
77
+ }
78
+
79
+ export type FaqDocument = {
80
+ _id: string
81
+ _type: "faq"
82
+ _createdAt: string
83
+ _updatedAt: string
84
+ _rev: string
85
+ brand: "musora" | "drumeo" | "pianote" | "singeo" | "guitareo" | "playbass"
86
+ /** FAQs */
87
+ questions?: {
88
+ _key: string
89
+ /** Web Only? */
90
+ web_only?: boolean
91
+ question?: string
92
+ answer?: string
93
+ }[]
94
+ }
@@ -0,0 +1,87 @@
1
+ import { Brands } from '../../lib/brands'
2
+ import type { Either } from '../../lib/ads/either'
3
+ import { Filters as f } from '../../lib/sanity/filter'
4
+ import { groq } from '../../lib/sanity/groq'
5
+ import { SanityQueryError } from '../../lib/sanity/runner'
6
+ import type {
7
+ FaqDocument,
8
+ PracticeGoalDocument,
9
+ StatsDocument,
10
+ TestimonialDocument,
11
+ } from '../../lib/sanity/types/marketing'
12
+
13
+ const excludeFromGeneratedIndex = [
14
+ 'fetchBrandDocument',
15
+ 'brandDocumentQuery',
16
+ 'faqProjection',
17
+ 'statsProjection',
18
+ ]
19
+
20
+ export function brandDocumentQuery(type: string, brand: Brands | string, projection?: string[]) {
21
+ const builder = groq()
22
+ .and(f.combine(f.type(type), f.brand(brand)))
23
+ .first()
24
+ return projection ? builder.select(...projection) : builder
25
+ }
26
+
27
+ const fetchBrandDocument = <T>(type: string, brand: Brands | string, projection?: string[]) =>
28
+ brandDocumentQuery(type, brand, projection).run<T>()
29
+
30
+ export function faqProjection(includeWebOnly: boolean): string[] | undefined {
31
+ return includeWebOnly ? undefined : ['...', 'questions[!web_only]']
32
+ }
33
+
34
+ export function statsProjection(isMusora: boolean): string[] | undefined {
35
+ if (isMusora) return undefined
36
+
37
+ const musoraFilter = f.combine(f.type('stats'), f.brand(Brands.Musora))
38
+ return ['...', `"total_student_count": *[${musoraFilter}][0].total_student_count`]
39
+ }
40
+
41
+ export async function fetchMarketingStats(
42
+ brand: Brands | string
43
+ ): Promise<Either<SanityQueryError, StatsDocument | null>> {
44
+ return fetchBrandDocument<StatsDocument>('stats', brand)
45
+ }
46
+
47
+ export async function fetchMarketingPracticeGoals(
48
+ brand: Brands | string
49
+ ): Promise<Either<SanityQueryError, PracticeGoalDocument | null>> {
50
+ return fetchBrandDocument<PracticeGoalDocument>('practice-goal', brand)
51
+ }
52
+
53
+ export async function fetchMarketingTestimonials(
54
+ brand: Brands | string
55
+ ): Promise<Either<SanityQueryError, TestimonialDocument | null>> {
56
+ return fetchBrandDocument<TestimonialDocument>('testimonial', brand)
57
+ }
58
+
59
+ export async function fetchMarketingFaqs(
60
+ brand: Brands | string,
61
+ includeWebOnly: boolean = true
62
+ ): Promise<Either<SanityQueryError, FaqDocument | null>> {
63
+ return fetchBrandDocument<FaqDocument>('faq', brand, faqProjection(includeWebOnly))
64
+ }
65
+
66
+ export interface MarketingBundle {
67
+ stats: (StatsDocument & { total_student_count?: string }) | null
68
+ practiceGoals: PracticeGoalDocument | null
69
+ testimonials: TestimonialDocument | null
70
+ faqs: FaqDocument | null
71
+ }
72
+
73
+ export async function fetchMarketingAll(
74
+ brand: Brands | string,
75
+ includeWebOnlyFaqs: boolean = true
76
+ ): Promise<Either<SanityQueryError, MarketingBundle>> {
77
+ const isMusora = brand === Brands.Musora
78
+
79
+ return groq
80
+ .composite({
81
+ stats: brandDocumentQuery('stats', brand, statsProjection(isMusora)),
82
+ practiceGoals: brandDocumentQuery('practice-goal', brand),
83
+ testimonials: brandDocumentQuery('testimonial', brand),
84
+ faqs: brandDocumentQuery('faq', brand, faqProjection(includeWebOnlyFaqs)),
85
+ })
86
+ .run<MarketingBundle>()
87
+ }
@@ -31,3 +31,11 @@ export const allStartedOrCompleted = async (
31
31
  updatedAfter: Math.floor(Date.now() / 1000) - SIXTY_DAYS_IN_SECONDS,
32
32
  })
33
33
  .then((r) => r.data)
34
+
35
+ export const percentByContentId = async (brand?: string): Promise<Record<number, number>> =>
36
+ db.contentProgress
37
+ .startedOrCompleted({
38
+ brand,
39
+ updatedAfter: Math.floor(Date.now() / 1000) - SIXTY_DAYS_IN_SECONDS,
40
+ })
41
+ .then((r) => Object.fromEntries(r.data.map((p) => [p.content_id, p.progress_percent])))
@@ -1,8 +1,9 @@
1
1
  import * as state from './state'
2
2
  import * as collections from './collections'
3
3
  import * as utils from './utils'
4
+ import * as mutations from './mutations'
4
5
 
5
- export const Progress = { ...state, ...collections, ...utils }
6
+ export const Progress = { ...state, ...collections, ...utils, ...mutations }
6
7
 
7
8
  export type {
8
9
  ProgressContentFilter,
@@ -34,7 +34,10 @@ export const getAncestorAndSiblingIds = (
34
34
  const parentId = hierarchy?.parents?.[contentId]
35
35
  if (!parentId) return []
36
36
 
37
- if (parentId === contentId) return []
37
+ if (parentId === contentId) {
38
+ console.error('Circular dependency detected for contentId', contentId)
39
+ return []
40
+ }
38
41
 
39
42
  const siblingIds = hierarchy?.children?.[parentId] ?? []
40
43
  const allIds = [
@@ -0,0 +1,14 @@
1
+ import { COLLECTION_TYPE, CollectionParameter } from '../../sync/models/ContentProgress'
2
+
3
+ export const filterOutLearningPathsForDuplication = (
4
+ progresses: Record<number, number>,
5
+ collection: CollectionParameter
6
+ ): Record<number, number> =>
7
+ Object.fromEntries(
8
+ Object.entries(progresses).filter(([id]) => {
9
+ if (collection.type === COLLECTION_TYPE.LEARNING_PATH) {
10
+ return Number(id) !== collection.id
11
+ }
12
+ return true
13
+ })
14
+ )
@@ -2,7 +2,7 @@ import { db } from '../../sync'
2
2
  import type { ModelSerialized } from '../../sync/serializers'
3
3
  import ContentProgress, { CollectionParameter } from '../../sync/models/ContentProgress'
4
4
 
5
- type Selector<V> = (p: ModelSerialized<ContentProgress>) => V
5
+ type Selector<V> = (p: ModelSerialized<ContentProgress>) => V | null | undefined
6
6
 
7
7
  export const getByIds = async <V>(
8
8
  contentIds: number[],
@@ -30,10 +30,14 @@ export const getById = async <V>(
30
30
  if (!contentId) return defaultValue
31
31
  return db.contentProgress
32
32
  .getOneProgressByContentId(contentId, collection)
33
- .then((r) => (r.data ? selector(r.data) : defaultValue) ?? defaultValue)
33
+ .then((r) => (r.data ? selector(r.data) ?? defaultValue : defaultValue))
34
34
  }
35
35
 
36
- export const getByRecordIds = async <V>(ids: string[], selector: Selector<V>, defaultValue: V) => {
36
+ export const getByRecordIds = async <V>(
37
+ ids: string[],
38
+ selector: Selector<V>,
39
+ defaultValue: V
40
+ ): Promise<Record<string, V>> => {
37
41
  const progress = Object.fromEntries(ids.map((id) => [id, defaultValue]))
38
42
 
39
43
  await db.contentProgress.getSomeProgressByRecordIds(ids).then((r) => {