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.
- package/.agent/decisions/2026-05-20-content-progress-ts-part-5.md +39 -0
- package/.agent/decisions/2026-08-28-mu2-1877-marketing-sanity-queries.md +38 -0
- package/.claude/settings.local.json +71 -0
- package/CHANGELOG.md +14 -0
- package/package.json +2 -1
- package/src/index.d.ts +13 -0
- package/src/index.js +13 -0
- package/src/lib/ads/coproduct.ts +57 -10
- package/src/lib/ads/either.ts +11 -1
- package/src/lib/sanity/types/marketing.d.ts +94 -0
- package/src/services/marketing/marketing.ts +87 -0
- package/src/services/progress/collections.ts +8 -0
- package/src/services/progress/index.ts +2 -1
- package/src/services/progress/internal/bubble.ts +4 -1
- package/src/services/progress/internal/learning-path.ts +14 -0
- package/src/services/progress/internal/queries.ts +7 -3
- package/src/services/progress/mutations.ts +316 -0
- package/src/services/progress/state.ts +54 -33
- package/src/services/sanity.js +2 -2
- package/src/services/user/memberships.ts +1 -0
- package/src/version-info.js +1 -1
- package/tools/generate-sanity-types.cjs +212 -0
- package/.idea/codeStyles/Project.xml +0 -61
- package/.idea/codeStyles/codeStyleConfig.xml +0 -5
- package/.idea/dataSources/6549dc28-6466-4ea3-a66b-7750cfeae4e7/storage_v2/_src_/schema/information_schema.FNRwLQ.meta +0 -2
- package/.idea/dataSources/6549dc28-6466-4ea3-a66b-7750cfeae4e7/storage_v2/_src_/schema/mysql.osA4Bg.meta +0 -2
- package/.idea/dataSources/6549dc28-6466-4ea3-a66b-7750cfeae4e7/storage_v2/_src_/schema/performance_schema.kIw0nw.meta +0 -2
- package/.idea/dataSources/6549dc28-6466-4ea3-a66b-7750cfeae4e7/storage_v2/_src_/schema/sys.zb4BAA.meta +0 -2
- package/.idea/dataSources/6549dc28-6466-4ea3-a66b-7750cfeae4e7.xml +0 -25767
- package/.idea/dataSources/data_sources_history.xml +0 -23
- package/.idea/dataSources.local.xml +0 -19
- package/.idea/dataSources.xml +0 -12
- package/.idea/modules.xml +0 -8
- package/.idea/musora-content-services.iml +0 -8
- package/.idea/php.xml +0 -19
- package/.idea/prettier.xml +0 -6
- package/.idea/vcs.xml +0 -6
- package/.idea/workspace.xml +0 -349
- 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.
|
|
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,
|
package/src/lib/ads/coproduct.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
*
|
|
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
|
-
/**
|
|
44
|
-
|
|
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
|
-
/**
|
|
47
|
-
|
|
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
|
-
|
|
145
|
+
lmap<T>(fn: (l: L) => T): Coproduct<T, R> {
|
|
99
146
|
return new Left(fn(this.value))
|
|
100
147
|
}
|
|
101
148
|
|
|
102
|
-
|
|
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
|
-
|
|
202
|
+
lmap<T>(_fn: (l: L) => T): Coproduct<T, R> {
|
|
156
203
|
return new Right(this.value)
|
|
157
204
|
}
|
|
158
205
|
|
|
159
|
-
|
|
206
|
+
lflatMap<T>(_fn: (l: L) => Coproduct<T, R>): Coproduct<T, R> {
|
|
160
207
|
return new Right(this.value)
|
|
161
208
|
}
|
|
162
209
|
|
package/src/lib/ads/either.ts
CHANGED
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
import { Coproduct } from './coproduct'
|
|
2
2
|
|
|
3
|
-
/**
|
|
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)
|
|
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)
|
|
33
|
+
.then((r) => (r.data ? selector(r.data) ?? defaultValue : defaultValue))
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
export const getByRecordIds = async <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) => {
|