feat(peak-routes): add PeakPulse track point storage module — types, repository, routes, 10 tests

This commit is contained in:
saravanakumardb1 2026-03-01 07:40:19 -08:00
parent b20e1c6165
commit d10b898ba5
10 changed files with 1918 additions and 0 deletions

View File

@ -0,0 +1,387 @@
# Android Platform SDK — Extraction Plan & Roadmap
> **Proposed Package:** `ByteLystPlatformSDK` (Kotlin library, published as Maven local or included as composite build)
> **Location:** `learning_ai_common_plat/packages/kotlin-platform-sdk/`
> **Status:** ❌ Does not exist yet — ~2,500+ LOC duplicated across 3 Android apps
---
## 1. Executive Summary
Unlike iOS, which has a mature `ByteLystPlatformSDK` Swift Package with 13 components used by 5 apps, **Android has zero shared platform infrastructure**. Each Android app independently implements auth, telemetry, feature flags, kill switch, blob upload, licensing, and audit logging — with near-identical code differing only in product ID and SharedPreferences namespace.
This document proposes creating a **Kotlin Platform SDK** (`kotlin-platform-sdk`) that mirrors the Swift SDK's architecture, then migrating all 3 Android apps to use it.
---
## 2. Current State — Duplication Audit
### 2.1 Apps Scanned
| App | Repo | Android Package | Platform Files | Estimated Platform LOC |
| -------------- | ----------------------------------- | ---------------------- | ------------------------------- | ---------------------- |
| **LysnrAI** | `learning_voice_ai_agent` | `com.saravana.lysnrai` | 12 files | ~1,200 |
| **ChronoMind** | `learning_ai_clock` | `com.chronomind.app` | 7 files | ~800 |
| **MindLyst** | `learning_multimodal_memory_agents` | `com.mindlyst.android` | 5 files | ~550 |
| **JarvisJr** | `learning_ai_jarvis_jr` | — | 0 (Android not started) | 0 |
| **PeakPulse** | `learning_ai_peakpulse` | — | 0 (Android not started) | 0 |
| **NomGap** | `learning_ai_fastgap` | — | React Native (see separate doc) | — |
**Total duplicated platform LOC: ~2,550**
### 2.2 Per-Component Duplication
| Component | LysnrAI | ChronoMind | MindLyst | Diff Between Copies |
| ---------------------- | --------------------------- | ------------------------------------ | ------------------------------ | --------------------------------- |
| **AuthService** | ✅ 300+ LOC (OkHttp) | ✅ 344 LOC (HttpURLConnection, Hilt) | ✅ 232 LOC (HttpURLConnection) | productId, prefs name, DI style |
| **TelemetryService** | ⚠️ Presumed | ✅ 178 LOC (Hilt @Singleton) | ✅ 171 LOC (plain class) | productId, prefs name, class name |
| **FeatureFlagService** | ✅ 98 LOC (OkHttp, object) | ✅ 89 LOC (Hilt @Singleton) | ✅ similar | HTTP client, DI style |
| **KillSwitchService** | ✅ 64 LOC (OkHttp, object) | ❌ Not yet | ❌ Not yet | — |
| **BlobService** | ✅ 150 LOC (OkHttp, object) | ❌ Not yet | ❌ Not yet | — |
| **LicenseService** | ✅ 161 LOC (OkHttp, object) | ❌ N/A | ❌ N/A | — |
| **AuditLogger** | ✅ 72 LOC (file-based) | ❌ Not yet | ❌ Not yet | — |
| **BiometricAuth** | ✅ exists | ❌ Not yet | ❌ Not yet | — |
| **CertificatePinning** | ✅ exists | ❌ Not yet | ❌ Not yet | — |
| **PlatformApiClient** | ❌ (uses OkHttp inline) | ✅ 182 LOC (HttpURLConnection) | ✅ PlatformServiceClient | HTTP client choice |
### 2.3 Key Differences Between Copies
The duplicated code is **95% identical** with these product-specific differences:
| Dimension | LysnrAI | ChronoMind | MindLyst |
| ---------------- | ---------------------------------- | ------------------------------ | ---------------------------- |
| Product ID | `"lysnrai"` | `"chronomind"` | `"mindlyst"` |
| SharedPrefs name | `"lysnrai_auth"` | `"chronomind_auth"` | `"mindlyst_auth"` |
| Default base URL | `BuildConfig.PLATFORM_SERVICE_URL` | `"https://api.chronomind.app"` | `"http://10.0.2.2:4003/api"` |
| HTTP client | OkHttp | `HttpURLConnection` | `HttpURLConnection` |
| DI framework | None (singleton objects) | Hilt (`@Singleton @Inject`) | None (plain class) |
| Flag caching | SharedPrefs JSON | In-memory only | In-memory only |
---
## 3. Proposed Kotlin Platform SDK
### 3.1 Architecture
Mirror the Swift SDK's component-for-component design:
```
learning_ai_common_plat/packages/kotlin-platform-sdk/
├── build.gradle.kts # Pure Kotlin library (no Android framework deps in core)
├── src/
│ ├── main/kotlin/com/bytelyst/platform/
│ │ ├── BLPlatformConfig.kt # Product config data class
│ │ ├── BLPlatformClient.kt # HTTP client (OkHttp-based)
│ │ ├── BLAuthClient.kt # Auth operations + token management
│ │ ├── BLTelemetryClient.kt # Telemetry queue + batch flush
│ │ ├── BLFeatureFlagClient.kt # Flag polling + caching
│ │ ├── BLKillSwitchClient.kt # Kill switch check (fail-open)
│ │ ├── BLBlobClient.kt # Azure Blob upload via SAS
│ │ ├── BLLicenseClient.kt # License activation + status
│ │ ├── BLAuditLogger.kt # Local rotating JSON audit log
│ │ ├── BLBiometricAuth.kt # BiometricPrompt wrapper
│ │ ├── BLCrashReporter.kt # Uncaught exception handler
│ │ └── BLSecureStore.kt # EncryptedSharedPreferences wrapper (Android Keystore)
│ └── test/kotlin/com/bytelyst/platform/
│ ├── BLAuthClientTest.kt
│ ├── BLTelemetryClientTest.kt
│ ├── BLFeatureFlagClientTest.kt
│ └── ...
└── gradle.properties
```
### 3.2 Component Mapping (Swift → Kotlin)
| # | Swift Component | Kotlin Component | Notes |
| --- | --------------------- | --------------------- | -------------------------------------------- |
| 1 | `BLPlatformConfig` | `BLPlatformConfig` | Data class, identical structure |
| 2 | `BLPlatformClient` | `BLPlatformClient` | OkHttp-based (not HttpURLConnection) |
| 3 | `BLKeychain` | `BLSecureStore` | EncryptedSharedPreferences (AndroidKeystore) |
| 4 | `BLTelemetryClient` | `BLTelemetryClient` | Coroutine-based flush, same event schema |
| 5 | `BLAuthClient` | `BLAuthClient` | StateFlow-based auth state |
| 6 | `BLFeatureFlagClient` | `BLFeatureFlagClient` | Coroutine polling + SharedPrefs cache |
| 7 | `BLSyncEngine` | `BLSyncEngine` | Generic sync with adapter interface |
| 8 | `BLBlobClient` | `BLBlobClient` | OkHttp multipart upload |
| 9 | `BLKillSwitchClient` | `BLKillSwitchClient` | Fail-open, same API |
| 10 | `BLLicenseClient` | `BLLicenseClient` | URL-encode key, same API |
| 11 | `BLBiometricAuth` | `BLBiometricAuth` | AndroidX BiometricPrompt |
| 12 | `BLCrashReporter` | `BLCrashReporter` | Thread.setDefaultUncaughtExceptionHandler |
| 13 | `BLAuditLogger` | `BLAuditLogger` | File-based rotating log (same as Swift) |
### 3.3 Design Decisions
| Decision | Choice | Rationale |
| ------------------ | ------------------------------------------ | -------------------------------------------------------------------------------------------- |
| **HTTP client** | OkHttp | Already used by LysnrAI; standard for Android; supports interceptors, timeouts, cert pinning |
| **Serialization** | kotlinx.serialization | Already used by all 3 apps; compile-time safe |
| **Concurrency** | Kotlin Coroutines | Already used by all 3 apps; StateFlow for reactive state |
| **DI approach** | Constructor injection (no Hilt dependency) | SDK must be DI-framework agnostic; apps wire via Hilt/Koin/manual |
| **Secure storage** | EncryptedSharedPreferences | Standard Android Keystore-backed storage; replaces plain SharedPreferences for tokens |
| **Min SDK** | API 26 (Android 8.0) | Matches all existing apps |
| **Distribution** | Composite Gradle build or `includeBuild()` | Similar to Swift Package local reference; no Maven Central needed |
### 3.4 BLPlatformConfig (Core)
```kotlin
package com.bytelyst.platform
data class BLPlatformConfig(
/** Product identifier (e.g., "chronomind", "lysnrai", "mindlyst") */
val productId: String,
/** Platform-service base URL (e.g., "http://localhost:4003/api") */
val baseUrl: String,
/** Platform string for telemetry (e.g., "android", "wear_os") */
val platform: String = "android",
/** Channel string for telemetry (e.g., "native", "keyboard") */
val channel: String = "native",
/** Application ID (e.g., "com.chronomind.app") */
val applicationId: String,
)
```
### 3.5 App Wrapper Pattern (Mirrors iOS)
Each Android app creates thin wrappers:
```kotlin
// di/PlatformModule.kt (Hilt example)
@Module
@InstallIn(SingletonComponent::class)
object PlatformModule {
@Provides @Singleton
fun providePlatformConfig(): BLPlatformConfig = BLPlatformConfig(
productId = "chronomind",
baseUrl = BuildConfig.PLATFORM_SERVICE_URL,
applicationId = BuildConfig.APPLICATION_ID,
)
@Provides @Singleton
fun provideAuthClient(config: BLPlatformConfig, context: @ApplicationContext Context): BLAuthClient =
BLAuthClient(config, BLSecureStore(context, config.applicationId))
@Provides @Singleton
fun provideTelemetryClient(config: BLPlatformConfig, context: @ApplicationContext Context): BLTelemetryClient =
BLTelemetryClient(config, context)
@Provides @Singleton
fun provideFeatureFlagClient(config: BLPlatformConfig): BLFeatureFlagClient =
BLFeatureFlagClient(config)
}
```
For apps without Hilt (MindLyst uses Koin):
```kotlin
// di/PlatformModule.kt (Koin example)
val platformModule = module {
single { BLPlatformConfig(productId = "mindlyst", baseUrl = "...", applicationId = "com.mindlyst.android") }
single { BLSecureStore(androidContext(), get<BLPlatformConfig>().applicationId) }
single { BLAuthClient(get(), get()) }
single { BLTelemetryClient(get(), androidContext()) }
single { BLFeatureFlagClient(get()) }
}
```
---
## 4. Migration Plan
### Phase 1: Create SDK (Sprint 1 — 3 days)
| Task | Effort | Priority |
| ------------------------------------------------------- | ------ | -------- |
| Set up `packages/kotlin-platform-sdk/` Gradle project | 1h | P0 |
| Implement `BLPlatformConfig` | 0.5h | P0 |
| Implement `BLPlatformClient` (OkHttp wrapper) | 3h | P0 |
| Implement `BLSecureStore` (EncryptedSharedPreferences) | 2h | P0 |
| Implement `BLAuthClient` (extract from ChronoMind) | 4h | P0 |
| Implement `BLTelemetryClient` (extract from ChronoMind) | 3h | P0 |
| Implement `BLFeatureFlagClient` (extract from LysnrAI) | 2h | P0 |
| Implement `BLKillSwitchClient` (extract from LysnrAI) | 1h | P0 |
| Write unit tests (JUnit5 + MockWebServer) | 4h | P0 |
**Deliverable:** SDK compiles, 7 core components, ~30 tests
### Phase 2: Migrate ChronoMind (Sprint 2 — 1 day)
ChronoMind is the best first migration target: Hilt DI, clean structure, 7 files to replace.
| Task | Effort |
| ---------------------------------------------------------------------------------------------------- | ------ |
| Add `includeBuild("../learning_ai_common_plat/packages/kotlin-platform-sdk")` to settings.gradle.kts | 0.5h |
| Create `di/PlatformModule.kt` with Hilt providers | 1h |
| Replace `auth/AuthService.kt` (344 LOC → ~20 LOC Hilt provider) | 1h |
| Replace `telemetry/TelemetryService.kt` (178 LOC → inject `BLTelemetryClient`) | 1h |
| Replace `telemetry/FeatureFlagService.kt` (89 LOC → inject `BLFeatureFlagClient`) | 0.5h |
| Replace `sync/PlatformApiClient.kt` (182 LOC → product-specific sync only) | 1h |
| Verify all tests pass | 1h |
**LOC saved:** ~800 LOC removed from ChronoMind
### Phase 3: Migrate MindLyst (Sprint 2 — 1 day)
| Task | Effort |
| ------------------------------------------------- | ------ |
| Add composite build reference | 0.5h |
| Replace `auth/AuthService.kt` (232 LOC) | 1h |
| Replace `telemetry/TelemetryService.kt` (171 LOC) | 1h |
| Replace `telemetry/FeatureFlagService.kt` | 0.5h |
| Replace `api/PlatformServiceClient.kt` | 0.5h |
| Verify Koin wiring | 1h |
**LOC saved:** ~550 LOC removed from MindLyst
### Phase 4: Migrate LysnrAI (Sprint 3 — 2 days)
LysnrAI has the most platform files (12) and uses OkHttp throughout.
| Task | Effort |
| --------------------------------------------- | ------ |
| Add composite build reference | 0.5h |
| Replace `data/FeatureFlagService.kt` (98 LOC) | 0.5h |
| Replace `data/KillSwitchService.kt` (64 LOC) | 0.5h |
| Replace `data/BlobService.kt` (150 LOC) | 1h |
| Replace `data/LicenseService.kt` (161 LOC) | 1h |
| Replace `data/AuditLogger.kt` (72 LOC) | 0.5h |
| Replace `data/BiometricAuth.kt` | 1h |
| Replace auth flows to use `BLAuthClient` | 2h |
| Add telemetry client (if not present) | 1h |
| Move remaining `data/*.kt` platform files | 1h |
| Verify all tests pass | 1h |
**LOC saved:** ~1,200 LOC removed from LysnrAI
### Phase 5: Extended Components (Sprint 4 — 2 days)
| Task | Effort |
| ---------------------------------- | ------ |
| Implement `BLBlobClient` in SDK | 2h |
| Implement `BLLicenseClient` in SDK | 2h |
| Implement `BLAuditLogger` in SDK | 1h |
| Implement `BLBiometricAuth` in SDK | 2h |
| Implement `BLCrashReporter` in SDK | 2h |
| Implement `BLSyncEngine` in SDK | 4h |
| Write tests for all new components | 3h |
---
## 5. Gradle Integration
### 5.1 SDK build.gradle.kts
```kotlin
// packages/kotlin-platform-sdk/build.gradle.kts
plugins {
kotlin("jvm") version "2.1.0"
kotlin("plugin.serialization") version "2.1.0"
}
group = "com.bytelyst.platform"
version = "0.1.0"
dependencies {
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0")
// Android-specific (optional, for SecureStore and BiometricAuth)
compileOnly("androidx.security:security-crypto:1.1.0-alpha06")
compileOnly("androidx.biometric:biometric:1.2.0-alpha05")
testImplementation(kotlin("test"))
testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0")
}
```
### 5.2 Consumer App settings.gradle.kts
```kotlin
// android/settings.gradle.kts
includeBuild("../../learning_ai_common_plat/packages/kotlin-platform-sdk") {
dependencySubstitution {
substitute(module("com.bytelyst.platform:kotlin-platform-sdk"))
.using(project(":"))
}
}
```
### 5.3 Consumer App build.gradle.kts
```kotlin
// android/app/build.gradle.kts
dependencies {
implementation("com.bytelyst.platform:kotlin-platform-sdk")
// ... app-specific deps
}
```
---
## 6. Testing Strategy
| Level | Tool | What to Test |
| --------------- | -------------------------- | ---------------------------------------------------------------- |
| **Unit** | JUnit5 + MockWebServer | HTTP requests, JSON serialization, token management, queue logic |
| **Integration** | Robolectric | EncryptedSharedPreferences, BiometricPrompt |
| **E2E** | Espresso (in consumer app) | Login flow, telemetry flush, flag polling |
**Target:** 60+ tests in the SDK (matching Swift SDK's coverage aspirations).
---
## 7. Timeline Summary
| Sprint | Duration | Deliverable |
| --------- | ---------- | ------------------------------------------------------------------ |
| Sprint 1 | 3 days | SDK v0.1 — 7 core components + 30 tests |
| Sprint 2 | 2 days | ChronoMind + MindLyst migrated (~1,350 LOC removed) |
| Sprint 3 | 2 days | LysnrAI migrated (~1,200 LOC removed) |
| Sprint 4 | 2 days | Extended components (Blob, License, Audit, Biometric, Crash, Sync) |
| **Total** | **9 days** | **All 3 apps on SDK, ~2,550 LOC removed, 60+ SDK tests** |
---
## 8. Future Android Apps
Once the SDK exists, new Android apps (JarvisJr Phase 4, PeakPulse future) get platform infrastructure for free:
1. Add `includeBuild()` in settings.gradle.kts
2. Add `implementation("com.bytelyst.platform:kotlin-platform-sdk")` in build.gradle.kts
3. Create DI wiring (Hilt `@Module` or Koin `module {}`)
4. Focus 100% on app-specific business logic
**Estimated setup time for a new app:** 30 minutes (vs. 2-3 days copying + adapting platform code today).
---
## 9. Design Token Integration
The `@bytelyst/design-tokens` package already generates:
- `tokens.css` (web)
- `tokens.ts` (web)
- `MindLystTokens.kt` (Kotlin)
- `MindLystTheme.swift` (Swift)
**Action needed:** Extend the token generator to produce per-product Kotlin token files:
- `ChronoMindTokens.kt`
- `LysnrAITokens.kt`
- `PeakPulseTokens.kt`
- `JarvisJrTokens.kt`
Or create a single `BLDesignTokens.kt` with product-specific color palette selection at runtime via `BLPlatformConfig.productId`.
---
## 10. Risk Mitigation
| Risk | Mitigation |
| ------------------------------------------ | ------------------------------------------------------------------------------------ |
| **OkHttp version conflict** | SDK declares OkHttp as `api` dependency; consumers use same version |
| **Hilt vs Koin** | SDK uses constructor injection (no DI annotations); each app wires its own way |
| **EncryptedSharedPreferences on API < 23** | `minSdk = 26` across all apps; not an issue |
| **Breaking changes** | Semantic versioning; test in ChronoMind first (smallest Android app) |
| **Gradle composite build issues** | Document exact `includeBuild()` + `dependencySubstitution` setup |
| **CI builds without sibling repo** | Pack SDK into `.aar` tarball for CI (same pattern as Swift Package + docker-prep.sh) |

View File

@ -0,0 +1,283 @@
# iOS Platform SDK — Best Practices & Reference
> **Package:** `ByteLystPlatformSDK` (Swift Package)
> **Location:** `learning_ai_common_plat/packages/swift-platform-sdk/`
> **Status:** ✅ Production-ready — 13 components, 5 apps migrated
---
## 1. Executive Summary
The **ByteLystPlatformSDK** is a shared Swift Package that provides 13 platform-agnostic components consumed by all iOS/watchOS/macOS apps in the ByteLyst ecosystem. Each product app creates thin wrapper files (515 LOC each) in a `Platform/` directory that inject product-specific config (productId, bundleId, baseURL) into the SDK.
**Goal:** Only app-specific business logic lives in the app repo. All platform infrastructure (auth, telemetry, keychain, feature flags, kill switch, blob storage, crash reporting, sync, licensing, biometrics, audit logging) comes from the shared SDK.
---
## 2. SDK Components (13)
| # | Component | Description | LOC |
| --- | --------------------- | ------------------------------------------------------------------------- | ---- |
| 1 | `BLPlatformConfig` | Product-specific config struct (productId, baseURL, bundleId, appGroupId) | 65 |
| 2 | `BLPlatformClient` | Generic HTTP client with auth injection, x-request-id, fire-and-forget | ~150 |
| 3 | `BLKeychain` | Keychain CRUD (configurable service string) | ~80 |
| 4 | `BLTelemetryClient` | Telemetry event queue + batch flush to platform-service | ~200 |
| 5 | `BLAuthClient` | Full auth: login, register, refresh, password ops, email verification | ~250 |
| 6 | `BLFeatureFlagClient` | Feature flag polling from `/flags/poll` with caching | ~120 |
| 7 | `BLSyncEngine` | Generic offline-first sync with `BLSyncAdapter` protocol | ~180 |
| 8 | `BLBlobClient` | Azure Blob Storage upload via SAS tokens | ~130 |
| 9 | `BLKillSwitchClient` | Kill switch check (fail-open, includes X-Request-Id) | ~60 |
| 10 | `BLLicenseClient` | License key activation + status (URL-encodes key) | ~100 |
| 11 | `BLBiometricAuth` | Face ID / Touch ID (`#if canImport(LocalAuthentication)` for watchOS) | ~80 |
| 12 | `BLCrashReporter` | MetricKit crash + performance reporting (`#if canImport(MetricKit)`) | ~120 |
| 13 | `BLAuditLogger` | Local rotating JSON audit log (iso8601 encode/decode matched) | ~150 |
**Total SDK:** ~1,870 LOC across 13 source files + 1 test file.
---
## 3. App Adoption Matrix
| Component | LysnrAI | ChronoMind | PeakPulse | JarvisJr | MindLyst |
| --------------------- | --------------------- | ---------------------- | ---------------------- | ---------------------- | ------------------------ |
| `BLPlatformConfig` | ✅ Config.swift | ✅ (inline) | ✅ Config.swift | ✅ Config.swift | ⚠️ Not yet |
| `BLPlatformClient` | ✅ (internal) | ✅ (internal) | ✅ (internal) | ✅ (internal) | ⚠️ PlatformServiceClient |
| `BLKeychain` | ✅ KeychainHelper | ✅ KeychainHelper | ✅ KeychainHelper | ✅ KeychainHelper | ✅ KeychainHelper |
| `BLTelemetryClient` | ✅ TelemetryService | ✅ TelemetryService | ✅ TelemetryService | ✅ TelemetryService | ✅ TelemetryService |
| `BLAuthClient` | ✅ AuthService | ✅ AuthService | ✅ AuthService | ✅ AuthService | ✅ AuthService |
| `BLFeatureFlagClient` | ✅ FeatureFlagService | ✅ FeatureFlagService | ✅ FeatureFlagService | ✅ FeatureFlagService | ✅ FeatureFlagService |
| `BLSyncEngine` | ✅ CloudSyncService | ✅ PlatformSyncManager | ✅ PlatformSyncManager | ✅ PlatformSyncManager | ⚠️ Not yet |
| `BLBlobClient` | ✅ BlobService | ❌ Not needed | ❌ Phase 2 | ❌ Phase 2 | ❌ Phase 2 |
| `BLKillSwitchClient` | ✅ KillSwitchService | ❌ Not yet | ✅ KillSwitchService | ✅ KillSwitchService | ❌ Not yet |
| `BLLicenseClient` | ✅ LicenseService | ❌ N/A | ❌ N/A | ❌ N/A | ❌ N/A |
| `BLBiometricAuth` | ✅ BiometricAuth | ❌ Not yet | ❌ Phase 3 | ❌ Phase 3 | ❌ Phase 3 |
| `BLCrashReporter` | ❌ Not yet | ✅ CrashReporter | ✅ CrashReporter | ✅ CrashReporter | ❌ Not yet |
| `BLAuditLogger` | ✅ AuditLogger | ❌ Not yet | ❌ Phase 2 | ❌ Phase 2 | ❌ Not yet |
**Legend:** ✅ = migrated to SDK wrapper | ⚠️ = hand-rolled (needs migration) | ❌ = not used / future phase
---
## 4. Integration Pattern
### 4.1 Xcode Project Setup
Add the SDK as a local Swift Package dependency:
```
File → Add Package Dependencies → Add Local...
→ Select: ../learning_ai_common_plat/packages/swift-platform-sdk/
```
Or in `Package.swift` / `project.yml`:
```yaml
packages:
ByteLystPlatformSDK:
path: ../../learning_ai_common_plat/packages/swift-platform-sdk
```
### 4.2 App Config (Platform/Config.swift)
Every app MUST have a `Config.swift` that creates the `BLPlatformConfig`:
```swift
import ByteLystPlatformSDK
enum AppConfig {
static let platform = BLPlatformConfig(
productId: "peakpulse", // ← product-specific
baseURL: "http://localhost:4003/api", // ← from Info.plist in prod
platform: "ios",
channel: "native",
bundleId: "com.saravana.peakpulse", // ← product-specific
appGroupId: "group.com.saravana.peakpulse" // ← optional
)
}
```
### 4.3 Thin Wrapper Pattern
Each wrapper file is 515 LOC and delegates to the SDK:
```swift
// Platform/KeychainHelper.swift
import ByteLystPlatformSDK
enum KeychainHelper {
private static let service = AppConfig.platform.bundleId
@discardableResult
static func save(key: String, value: String) -> Bool {
BLKeychain.save(service: service, key: key, value: value)
}
static func read(key: String) -> String? {
BLKeychain.read(service: service, key: key)
}
@discardableResult
static func delete(key: String) -> Bool {
BLKeychain.delete(service: service, key: key)
}
}
```
### 4.4 Directory Structure
```
ios/
├── <AppName>/
│ ├── App/ # Entry point
│ ├── Platform/ # ← SDK wrappers (515 LOC each)
│ │ ├── Config.swift # BLPlatformConfig
│ │ ├── KeychainHelper.swift
│ │ ├── TelemetryService.swift
│ │ ├── AuthService.swift
│ │ ├── FeatureFlagService.swift
│ │ ├── KillSwitchService.swift
│ │ ├── CrashReporter.swift
│ │ └── PlatformSyncManager.swift
│ ├── Shared/ # App-specific business logic
│ ├── Views/ # SwiftUI screens
│ ├── ViewModels/ # @Observable MVVM
│ ├── Models/ # Data models
│ ├── Services/ # App-specific services
│ └── Theme/ # Design tokens
```
---
## 5. Rules & Conventions
### MUST follow
1. **Every app must have `Platform/Config.swift`** with a `BLPlatformConfig` instance
2. **Platform/ wrappers must be thin** — no business logic, just inject config + delegate
3. **Never copy SDK code into the app** — always `import ByteLystPlatformSDK`
4. **Never hardcode productId** — read from `AppConfig.platform.productId`
5. **Never hardcode baseURL** — use `BLPlatformConfig.fromInfoPlist()` or `AppConfig.platform.baseURL`
6. **Keychain keys must be bare** (e.g., `access_token`, NOT `productId_access_token`) — the bundleId service string provides namespace isolation
7. **All logging via `os.Logger`** — never `print()`
8. **All colors from theme** — never hardcode hex values
### MUST NOT do
1. Never implement auth flows directly — use `BLAuthClient` via wrapper
2. Never create raw `URLSession` requests to platform-service — use `BLPlatformClient`
3. Never roll your own telemetry queue — use `BLTelemetryClient`
4. Never implement feature flag polling — use `BLFeatureFlagClient`
5. Never access Keychain directly — use `BLKeychain` via wrapper
---
## 6. Gap Analysis & Remediation
### 6.1 MindLyst iOS — Needs Migration
MindLyst iOS (`iosApp/Services/`) still has hand-rolled platform files not using the SDK:
| File | Status | Action |
| --------------------------------------------- | -------------------------- | ----------------------------- |
| `iosApp/Services/KeychainHelper.swift` | ⚠️ Hand-rolled | Migrate to SDK wrapper |
| `iosApp/Services/TelemetryService.swift` | ⚠️ Hand-rolled | Migrate to SDK wrapper |
| `iosApp/Services/AuthService.swift` | ⚠️ Hand-rolled | Migrate to SDK wrapper |
| `iosApp/Services/FeatureFlagService.swift` | ⚠️ Hand-rolled | Migrate to SDK wrapper |
| `iosApp/Services/PlatformServiceClient.swift` | ⚠️ Hand-rolled HTTP client | Replace with BLPlatformClient |
| `iosApp/MindLystTheme.swift` | ✅ Auto-generated | Keep as-is (design tokens) |
**Effort:** ~2 hours. Create `iosApp/Platform/` directory, add 5 thin wrappers, delete hand-rolled files.
### 6.2 LysnrAI iOS — Directory Rename
LysnrAI wrappers are split across `Auth/` and `Util/` instead of a unified `Platform/` directory:
| Current Location | Should Be |
| --------------------------------------- | ------------------------------------------- |
| `LysnrAI/Auth/KeychainHelper.swift` | `LysnrAI/Platform/KeychainHelper.swift` |
| `LysnrAI/Auth/AuthService.swift` | `LysnrAI/Platform/AuthService.swift` |
| `LysnrAI/Auth/BiometricAuth.swift` | `LysnrAI/Platform/BiometricAuth.swift` |
| `LysnrAI/Util/TelemetryService.swift` | `LysnrAI/Platform/TelemetryService.swift` |
| `LysnrAI/Util/FeatureFlagService.swift` | `LysnrAI/Platform/FeatureFlagService.swift` |
| `LysnrAI/Util/KillSwitchService.swift` | `LysnrAI/Platform/KillSwitchService.swift` |
| `LysnrAI/Util/LicenseService.swift` | `LysnrAI/Platform/LicenseService.swift` |
| `LysnrAI/Util/BlobService.swift` | `LysnrAI/Platform/BlobService.swift` |
| `LysnrAI/Util/AuditLogger.swift` | `LysnrAI/Platform/AuditLogger.swift` |
**Effort:** ~1 hour. Move files, update Xcode project references.
### 6.3 Missing SDK Features
| Feature | Description | Priority |
| ---------------------- | ------------------------------------------------------------- | -------- |
| `BLNotificationClient` | UNUserNotificationCenter wrapper with permission + scheduling | P2 |
| `BLAppUpdateChecker` | App Store version check + in-app update prompt | P3 |
| `BLOnboardingTracker` | Track onboarding progress, first-launch flag | P3 |
| `BLReviewPrompt` | SKStoreReviewController with smart timing | P3 |
---
## 7. Testing
The SDK has a test target (`ByteLystPlatformSDKTests`) at `Tests/BLKeychainTests.swift`.
**Recommended additions:**
- Unit tests for each component using mock URLSession
- Integration tests against a local platform-service instance
- Snapshot tests for telemetry event schema compliance
Run tests:
```bash
swift test --package-path packages/swift-platform-sdk/
```
---
## 8. Versioning & Distribution
**Current:** Local Swift Package referenced via relative path (`../learning_ai_common_plat/packages/swift-platform-sdk/`).
**Future options (when open-sourcing or distributing):**
1. **Git tag versioning** — tag releases in common-plat, reference by version
2. **Binary XCFramework** — pre-build for CI speed
3. **Swift Package Registry** — when Apple's registry matures
**For now:** Local path reference is correct. All apps in the workspace reference the same source.
---
## 9. Quick Reference
### Add SDK to a new app
1. Add Swift Package dependency pointing to `../learning_ai_common_plat/packages/swift-platform-sdk/`
2. Create `Platform/Config.swift` with `BLPlatformConfig`
3. Create thin wrappers for each SDK component needed (start with KeychainHelper, TelemetryService, AuthService)
4. Import `ByteLystPlatformSDK` in each wrapper
### SDK source location
```
learning_ai_common_plat/packages/swift-platform-sdk/
├── Package.swift
├── Sources/
│ ├── BLPlatformConfig.swift
│ ├── BLPlatformClient.swift
│ ├── BLKeychain.swift
│ ├── BLTelemetryClient.swift
│ ├── BLAuthClient.swift
│ ├── BLFeatureFlagClient.swift
│ ├── BLSyncEngine.swift
│ ├── BLBlobClient.swift
│ ├── BLKillSwitchClient.swift
│ ├── BLLicenseClient.swift
│ ├── BLBiometricAuth.swift
│ ├── BLCrashReporter.swift
│ ├── BLAuditLogger.swift
│ └── ByteLystPlatformSDK.swift ← module-level docs only
└── Tests/
└── BLKeychainTests.swift
```

View File

@ -0,0 +1,363 @@
# React Native / TypeScript Client SDK — Gap Analysis & Extraction Plan
> **Scope:** `@bytelyst/*` client-side packages for React Native (Expo) and web PWAs
> **Location:** `learning_ai_common_plat/packages/`
> **Status:** ⚠️ Partial — 2 of 8 needed packages exist
---
## 1. Executive Summary
NomGap (React Native / Expo) is the only non-native mobile app in the ByteLyst ecosystem. It currently uses **2 shared packages** from common-plat (`@bytelyst/auth-client` and `@bytelyst/telemetry-client`) but still has **6 hand-rolled platform modules** that should be extracted into shared packages.
These same TypeScript client packages also benefit **ChronoMind web** (Next.js PWA), **MindLyst web**, and any future web or React Native products.
---
## 2. Current State
### 2.1 Packages That Exist
| Package | Location | Consumers | LOC | Status |
| ---------------------------- | ---------------------------- | ---------------------------------------------- | --- | ------------- |
| `@bytelyst/auth-client` | `packages/auth-client/` | NomGap, ChronoMind web, MindLyst web | 266 | ✅ Production |
| `@bytelyst/telemetry-client` | `packages/telemetry-client/` | NomGap, ChronoMind web, LysnrAI user-dashboard | 237 | ✅ Production |
**Both packages are:**
- Pure TypeScript (no Node.js deps, no React Native deps)
- Browser + React Native safe (use `globalThis.fetch`)
- Factory pattern (`createAuthClient()`, `createTelemetryClient()`)
- Configurable storage adapters (localStorage, MMKV, or custom)
### 2.2 NomGap Integration (Reference Implementation)
NomGap demonstrates the correct pattern for consuming shared packages:
```json
// package.json
{
"dependencies": {
"@bytelyst/auth-client": "file:../learning_ai_common_plat/packages/auth-client",
"@bytelyst/telemetry-client": "file:../learning_ai_common_plat/packages/telemetry-client"
}
}
```
**Auth wiring** (`src/api/auth-api.ts` — 73 LOC):
- Creates `AuthClient` via factory with MMKV storage adapter
- Re-exports convenience wrappers (`loginUser`, `registerUser`, etc.)
**Telemetry wiring** (`src/lib/telemetry.ts` — 95 LOC):
- Creates `TelemetryClient` via factory with MMKV storage adapter
- Re-exports domain-specific helpers (`trackFastingEvent`, `trackScreenView`)
### 2.3 Hand-Rolled Modules in NomGap (Need Extraction)
| Module | File | LOC | Should Be |
| ----------------- | -------------------------- | --- | -------------------------------------------------------------------------------- |
| **Feature flags** | `src/lib/feature-flags.ts` | 72 | `@bytelyst/feature-flag-client` |
| **Offline queue** | `src/lib/offline-queue.ts` | 124 | `@bytelyst/offline-queue` |
| **API client** | `src/api/client.ts` | 116 | Already have `@bytelyst/api-client` (server-side); need RN-safe variant or merge |
| **API config** | `src/api/config.ts` | 18 | Stays app-specific (reads Expo env vars) |
| **Kill switch** | — | 0 | `@bytelyst/kill-switch-client` (not yet implemented) |
| **Blob upload** | — | 0 | `@bytelyst/blob-client` (not yet implemented) |
| **License** | — | 0 | `@bytelyst/license-client` (not yet implemented) |
| **Biometric** | — | 0 | Uses `expo-local-authentication` directly (no shared pkg needed) |
---
## 3. Proposed New Packages
### 3.1 Package Matrix
| # | Package | Description | Est. LOC | Priority | Consumers |
| --- | ------------------------------- | ----------------------------------------------- | -------- | -------- | ---------------------------------------------------- |
| 1 | `@bytelyst/feature-flag-client` | Flag polling + caching (browser + RN safe) | ~120 | P0 | NomGap, ChronoMind web, MindLyst web |
| 2 | `@bytelyst/kill-switch-client` | Kill switch check (fail-open) | ~60 | P0 | NomGap, all web apps |
| 3 | `@bytelyst/offline-queue` | Persistent retry queue (configurable storage) | ~150 | P1 | NomGap, any offline-capable app |
| 4 | `@bytelyst/blob-client` | SAS token + upload to Azure Blob | ~120 | P1 | NomGap (audio), future web uploads |
| 5 | `@bytelyst/license-client` | License activation + status | ~100 | P2 | LysnrAI web (if needed) |
| 6 | `@bytelyst/platform-client` | Generic typed fetch wrapper (browser + RN safe) | ~130 | P0 | All consumer apps (replaces hand-rolled API clients) |
### 3.2 Existing Package — Needs Clarification
`@bytelyst/api-client` already exists in `packages/api-client/` but is designed for **server-to-server** (Node.js) communication (dashboard → platform-service). It uses Node.js-specific patterns.
**Decision needed:**
- **Option A:** Extend `@bytelyst/api-client` to be browser + RN safe (add `globalThis.fetch` path)
- **Option B:** Create separate `@bytelyst/platform-client` for client-side use ← **Recommended** (different auth model: bearer token from storage vs. httpOnly cookies)
---
## 4. Package Designs
### 4.1 `@bytelyst/feature-flag-client`
Extract from NomGap's `src/lib/feature-flags.ts`:
```typescript
// packages/feature-flag-client/src/index.ts
export interface FeatureFlagClientConfig {
baseUrl: string;
productId: string;
platform: string;
pollIntervalMs?: number; // default 5 min
storage?: { getItem(k: string): string | null; setItem(k: string, v: string): void };
}
export interface FeatureFlagClient {
init(params?: { userId?: string }): Promise<void>;
isEnabled(key: string): boolean;
getAllFlags(): Readonly<Record<string, boolean>>;
refresh(): Promise<void>;
stop(): void;
}
export function createFeatureFlagClient(config: FeatureFlagClientConfig): FeatureFlagClient;
```
**Key improvements over NomGap's hand-rolled version:**
- Optional persistent cache via storage adapter (survives app restart)
- Configurable poll interval
- Product ID from config (not hardcoded)
### 4.2 `@bytelyst/kill-switch-client`
```typescript
// packages/kill-switch-client/src/index.ts
export interface KillSwitchClientConfig {
baseUrl: string;
productId: string;
}
export interface KillSwitchResult {
disabled: boolean;
message: string | null;
}
export function createKillSwitchClient(config: KillSwitchClientConfig): {
check(): Promise<KillSwitchResult>;
};
```
**Behavior:** Fail-open (returns `{ disabled: false }` on network error).
### 4.3 `@bytelyst/offline-queue`
Extract from NomGap's `src/lib/offline-queue.ts`:
```typescript
// packages/offline-queue/src/index.ts
export interface QueueStorage {
getItem(key: string): string | null;
setItem(key: string, value: string): void;
}
export interface OfflineQueueConfig {
storageKey: string;
storage: QueueStorage;
maxRetries?: number; // default 5
maxQueueSize?: number; // default 50
}
export interface OfflineQueue {
enqueue(item: {
id: string;
action: string;
path: string;
payload: Record<string, unknown>;
}): void;
flush(
executor: (action: string, path: string, payload: Record<string, unknown>) => Promise<void>
): Promise<{ flushed: number; failed: number }>;
length(): number;
clear(): void;
}
export function createOfflineQueue(config: OfflineQueueConfig): OfflineQueue;
```
**Key improvements:**
- Storage adapter (not hardcoded to MMKV)
- Configurable max retries and queue size
- Product-agnostic (storageKey includes product prefix)
### 4.4 `@bytelyst/blob-client`
```typescript
// packages/blob-client/src/index.ts
export interface BlobClientConfig {
baseUrl: string;
productId: string;
getAccessToken: () => string | null;
}
export interface BlobClient {
upload(
data: Blob | ArrayBuffer,
container: string,
fileName: string,
contentType: string
): Promise<string | null>;
uploadAudio(data: Blob | ArrayBuffer, fileName: string): Promise<string | null>;
}
export function createBlobClient(config: BlobClientConfig): BlobClient;
```
**Flow:** Get SAS token from platform-service → Upload directly to Azure Blob → Return blob URL.
---
## 5. Migration Plan
### Phase 1: Core Extraction (Sprint 1 — 2 days)
| Task | Effort |
| ------------------------------------------------------------------------ | ------ |
| Create `packages/feature-flag-client/` with factory pattern | 3h |
| Create `packages/kill-switch-client/` with fail-open behavior | 1.5h |
| Create `packages/platform-client/` (generic typed fetch for client-side) | 3h |
| Write tests for all 3 packages (Vitest) | 3h |
| `pnpm build` — verify all packages compile | 0.5h |
### Phase 2: NomGap Migration (Sprint 1 — 1 day)
| Task | Effort |
| -------------------------------------------------------------------- | ------ |
| Add `@bytelyst/feature-flag-client` to NomGap `package.json` | 0.5h |
| Replace `src/lib/feature-flags.ts` with thin wrapper (~20 LOC) | 1h |
| Add `@bytelyst/kill-switch-client` + implement kill switch check | 1h |
| Replace `src/api/client.ts` with `@bytelyst/platform-client` wrapper | 1.5h |
| Run NomGap tests (`npm test`) — verify 283 tests pass | 0.5h |
### Phase 3: Web App Migration (Sprint 2 — 1 day)
| Task | Effort |
| ------------------------------------------------------------------ | ------ |
| Replace ChronoMind web `src/lib/feature-flags.ts` (if hand-rolled) | 1h |
| Add kill switch check to ChronoMind web, MindLyst web | 1.5h |
| Verify all web builds pass (`npm run typecheck && npm run build`) | 1h |
### Phase 4: Extended Packages (Sprint 2 — 2 days)
| Task | Effort |
| -------------------------------------------------------- | ------ |
| Create `packages/offline-queue/` (extract from NomGap) | 3h |
| Create `packages/blob-client/` (client-side blob upload) | 3h |
| Migrate NomGap `src/lib/offline-queue.ts` to package | 1h |
| Write tests for both packages | 3h |
---
## 6. Updated Package Inventory (After Completion)
### Client-Side Packages (Browser + React Native Safe)
| Package | Status | Used By |
| ------------------------------- | --------- | ---------------------------------------------- |
| `@bytelyst/auth-client` | ✅ Exists | NomGap, ChronoMind web, MindLyst web |
| `@bytelyst/telemetry-client` | ✅ Exists | NomGap, ChronoMind web, LysnrAI user-dashboard |
| `@bytelyst/feature-flag-client` | 🔲 New | NomGap, all web apps |
| `@bytelyst/kill-switch-client` | 🔲 New | NomGap, all web apps |
| `@bytelyst/platform-client` | 🔲 New | NomGap, ChronoMind web |
| `@bytelyst/offline-queue` | 🔲 New | NomGap |
| `@bytelyst/blob-client` | 🔲 New | NomGap, future web uploads |
| `@bytelyst/license-client` | 🔲 Future | LysnrAI web (if needed) |
### Server-Side Packages (Node.js / Fastify)
These already exist and are not affected:
| Package | Purpose |
| ------------------------- | ------------------------------------------------------------- |
| `@bytelyst/api-client` | Server-to-server fetch wrapper (dashboard → platform-service) |
| `@bytelyst/auth` | JWT issuance + password hashing (server-side) |
| `@bytelyst/cosmos` | Azure Cosmos DB client singleton |
| `@bytelyst/config` | Env loader + product identity |
| `@bytelyst/errors` | Typed HTTP errors |
| `@bytelyst/fastify-core` | Fastify app factory |
| `@bytelyst/logger` | Pino-based structured logging |
| `@bytelyst/events` | In-memory event bus |
| `@bytelyst/blob` | Server-side Azure Blob client |
| `@bytelyst/extraction` | Extraction service client |
| `@bytelyst/testing` | Test mocks + helpers |
| `@bytelyst/monitoring` | Health check utilities |
| `@bytelyst/react-auth` | React auth context factory |
| `@bytelyst/design-tokens` | Cross-platform token generator |
---
## 7. Conventions for Client-Side Packages
### MUST follow
1. **Zero Node.js dependencies** — use `globalThis.fetch`, not `node-fetch`
2. **Zero React dependencies** — pure TypeScript (consumable by any framework)
3. **Zero React Native dependencies** — no `react-native-*` imports
4. **Factory pattern**`createXxxClient(config)` returns an interface
5. **Configurable storage** — accept `{ getItem, setItem }` adapter (works with localStorage, MMKV, AsyncStorage)
6. **ESM + CJS dual export**`"type": "module"` with `exports` field
7. **Vitest for tests**`passWithNoTests: true` at root
8. **`pnpm build` must pass** before consumer `npm install`
9. **No `console.log`** — silent by default; errors returned, not thrown (for telemetry/flags)
### MUST NOT do
1. Never import from `react-native` or `expo-*`
2. Never assume `window` exists (use `globalThis`)
3. Never assume `navigator.sendBeacon` exists (feature-detect)
4. Never throw on network errors in fire-and-forget clients (telemetry, flags, kill switch)
5. Never hardcode product IDs — always from config
---
## 8. EAS / Docker Build Considerations
NomGap uses `file:` refs to `@bytelyst/*` packages. These break in EAS Build and Docker where the sibling repo isn't available.
**Existing workaround:** `scripts/docker-prep.sh` in each app repo:
1. `cd ../learning_ai_common_plat && pnpm build`
2. `pnpm pack` each consumed package into `.tgz`
3. Rewrite `package.json` `file:` refs to `file:./tarballs/xxx.tgz`
4. Build proceeds self-contained
5. `./scripts/docker-prep.sh --restore` undoes changes
**Action:** When adding new `@bytelyst/*` packages to NomGap, update `scripts/docker-prep.sh` to include them.
---
## 9. Timeline Summary
| Sprint | Duration | Deliverable |
| --------- | ---------- | --------------------------------------------------------------------------------- |
| Sprint 1 | 3 days | 3 new packages + NomGap migrated |
| Sprint 2 | 3 days | 2 more packages + web apps migrated |
| **Total** | **6 days** | **8 client-side packages (2 existing + 6 new), NomGap + web apps on shared SDKs** |
---
## 10. Cross-Platform SDK Comparison
| Component | Swift SDK | Kotlin SDK (Proposed) | TS Client Packages |
| --------------- | --------------------- | --------------------- | ----------------------------------- |
| Platform config | `BLPlatformConfig` | `BLPlatformConfig` | Config per app (`api/config.ts`) |
| HTTP client | `BLPlatformClient` | `BLPlatformClient` | `@bytelyst/platform-client` |
| Secure storage | `BLKeychain` | `BLSecureStore` | Storage adapter pattern |
| Auth | `BLAuthClient` | `BLAuthClient` | `@bytelyst/auth-client` ✅ |
| Telemetry | `BLTelemetryClient` | `BLTelemetryClient` | `@bytelyst/telemetry-client` ✅ |
| Feature flags | `BLFeatureFlagClient` | `BLFeatureFlagClient` | `@bytelyst/feature-flag-client` 🔲 |
| Sync engine | `BLSyncEngine` | `BLSyncEngine` | `@bytelyst/offline-queue` 🔲 |
| Blob upload | `BLBlobClient` | `BLBlobClient` | `@bytelyst/blob-client` 🔲 |
| Kill switch | `BLKillSwitchClient` | `BLKillSwitchClient` | `@bytelyst/kill-switch-client` 🔲 |
| License | `BLLicenseClient` | `BLLicenseClient` | `@bytelyst/license-client` (future) |
| Biometric | `BLBiometricAuth` | `BLBiometricAuth` | expo-local-authentication (native) |
| Crash report | `BLCrashReporter` | `BLCrashReporter` | Sentry/Expo Updates (native) |
| Audit log | `BLAuditLogger` | `BLAuditLogger` | Not needed (telemetry covers it) |

View File

@ -0,0 +1,518 @@
# Referrals Container — Partition Key Migration Plan
> **Status:** Planned
> **Priority:** P1
> **Risk:** Medium (silent data failures on point reads)
> **Date:** 2026-03-01
> **Discovered:** Azure Connection Audit (see `docs/WINDSURF/AZURE_CONNECTION_AUDIT.md`)
---
## 1. Problem Statement
The `referrals` Cosmos DB container has a **3-way partition key mismatch** across the ecosystem. Four codebases declare different partition keys for the same container name, and the platform-service itself has an internal inconsistency between its container definition and its repository code.
### Current State
| Codebase | File | Declared PK | PK Used in Point Reads |
| ------------------------------------- | ------------------------------------------------------------------------ | ------------- | -------------------------------- |
| **platform-service** `cosmos-init.ts` | `services/platform-service/src/lib/cosmos-init.ts:15` | `/id` | — |
| **platform-service** `repository.ts` | `services/platform-service/src/modules/referrals/repository.ts:63,81,84` | — | `referrerId` |
| **admin-web** `cosmos.ts` | `dashboards/admin-web/src/lib/cosmos.ts:33` | `/referrerId` | N/A (calls platform-service API) |
| **user-dashboard-web** `cosmos.ts` | `user-dashboard-web/src/lib/cosmos.ts:30` | `/referrerId` | N/A (calls platform-service API) |
| **MindLyst** `cosmos.ts` | `mindlyst-native/web/src/lib/cosmos.ts:60` | `/userId` | `userId` |
### Critical Bug
**Platform-service `cosmos-init.ts` declares `/id` but `repository.ts` uses `referrerId` as the partition key value in point reads.**
```typescript
// cosmos-init.ts — declares /id
referrals: { partitionKeyPath: '/id' },
// repository.ts — uses referrerId as partition key
export async function getById(id: string, referrerId: string): Promise<ReferralDoc | null> {
const { resource } = await container().item(id, referrerId).read<ReferralDoc>();
// ^^^ ^^^^^^^^^^
// id PK value = referrerId
// But container PK is /id, so this should be container().item(id, id)
return resource ?? null;
}
```
If the container was created by `cosmos-init.ts` (with `/id`), then `getById()` and `update()` pass the wrong partition key value. Cosmos DB will:
- Return `404 Not Found` on reads (silent failure, returns `null`)
- Fail on `replace()` operations
**This means the platform-service referral module may already be silently broken in production if `cosmos-init.ts` was the first to create the container.**
---
## 2. Schema Differences
The two main consumers (platform-service and MindLyst) use fundamentally different document schemas in the same container.
### Platform-Service Schema (LysnrAI)
Single document type per referral:
```typescript
interface ReferralDoc {
id: string; // Unique referral ID
productId: string; // "lysnrai"
referrerId: string; // User ID of the referrer
referrerEmail: string;
referredUserId: string | null;
referredEmail: string;
status: 'pending' | 'signed_up' | 'subscribed' | 'rewarded';
referrerRewardTokens: number;
referrerRewarded: boolean;
referredRewarded: boolean;
createdAt: string;
completedAt: string | null;
}
```
### MindLyst Schema
Two document types in the same container, distinguished by `docType`:
```typescript
// Referral links
interface ReferralLink {
id: string; // "reflink_<uuid>"
userId: string; // Referrer's user ID (= partition key)
productId: string; // "mindlyst"
docType: 'link';
code: string; // "ML-XXXXXX"
url: string;
createdAt: string;
}
// Referral events
interface ReferralEvent {
id: string; // "ref_<uuid>"
userId: string; // Referrer's user ID (= partition key)
productId: string; // "mindlyst"
docType: 'event';
referrerId: string; // Same as userId
referredEmail: string;
referralCode: string;
status: 'invited' | 'installed' | 'activated' | 'rewarded';
createdAt: string;
activatedAt: string | null;
}
```
### Key Differences
| Aspect | Platform-Service | MindLyst |
| ------------------ | ------------------------------------------------ | ----------------------------------------------- |
| **Doc types** | 1 (referral) | 2 (link + event) |
| **Referrer field** | `referrerId` | `userId` |
| **Status values** | `pending`, `signed_up`, `subscribed`, `rewarded` | `invited`, `installed`, `activated`, `rewarded` |
| **Reward model** | Token-based (`referrerRewardTokens`) | Pro month extension |
| **Referral code** | Generated externally | `ML-XXXXXX` code in doc |
| **ID format** | UUID | `reflink_<uuid>` / `ref_<uuid>` |
---
## 3. Root Cause
The mismatch happened because:
1. **Platform-service growth module** was built first with `/id` in `cosmos-init.ts` (generic pattern for lookup-by-ID containers).
2. **Platform-service repository code** was written to use `referrerId` for point reads, assuming the partition key was `/referrerId`. This internal inconsistency was never caught because:
- Tests mock the Cosmos client, so point reads succeed regardless
- The container may not have been used in production yet
3. **Admin/user dashboards** declared `/referrerId` in their local `cosmos.ts`, matching the repository code's intent (not the `cosmos-init.ts` definition).
4. **MindLyst** was built independently with its own referral model, using `/userId` as the partition key (consistent with all other MindLyst containers).
---
## 4. Migration Options
### Option A: Separate Containers (Recommended)
**Create a dedicated `mindlyst_referrals` container for MindLyst. Fix the platform-service container to use `/referrerId`.**
| Action | Scope | Risk |
| ------------------------------------------------- | --------------------------- | ---------------------------- |
| Fix `cosmos-init.ts` to use `/referrerId` | platform-service | Low (if no prod data exists) |
| Add `mindlyst_referrals` container with `/userId` | platform-service + MindLyst | Low |
| Update MindLyst to use `mindlyst_referrals` | MindLyst | Low |
**Pros:**
- No data migration needed (each product gets its own container)
- Each product keeps its own schema and partition strategy
- Clean separation by productId
- Follows the existing pattern (e.g., `jarvis_agents`, `peak_sessions`)
**Cons:**
- Extra container cost (~$0.25/month on Serverless)
- Two containers to manage for referrals
### Option B: Unified Container with `/referrerId`
**Migrate all referral data to a single container with `/referrerId`. MindLyst renames `userId``referrerId` in its documents.**
| Action | Scope | Risk |
| ----------------------------------------------------------- | ---------------- | ----------------------- |
| Fix `cosmos-init.ts` to use `/referrerId` | platform-service | Low |
| Create new container `referrals_v2` with `/referrerId` | Azure | Low |
| Migrate existing documents, mapping `userId``referrerId` | Migration script | Medium |
| Update MindLyst code to use `referrerId` field | MindLyst | Medium |
| Delete old `referrals` container | Azure | High (after validation) |
**Pros:**
- Single container for all products
- Unified partition strategy
**Cons:**
- Requires data migration script
- MindLyst code changes across multiple files
- Schema unification needed (different status values, ID formats)
- Higher risk of breaking changes
### Option C: Migrate MindLyst to Platform-Service API
**Long-term goal: MindLyst calls the platform-service referrals API instead of accessing Cosmos directly.**
This is the correct architectural direction but requires:
1. Extending platform-service referrals module to support MindLyst's link+event model
2. Adding `docType` support or separate endpoints
3. MindLyst drops direct Cosmos access for referrals
**This should be Phase 2, after Option A stabilizes the immediate partition key issue.**
---
## 5. Recommended Plan: Option A + C
### Phase 1: Immediate Fix (Option A) — Low Risk
**Goal:** Fix the partition key mismatch so no code is silently broken.
#### Step 1.1: Fix platform-service `cosmos-init.ts`
Change the partition key from `/id` to `/referrerId`:
```diff
- referrals: { partitionKeyPath: '/id' },
+ referrals: { partitionKeyPath: '/referrerId' },
```
**⚠️ Prerequisite:** Determine if a `referrals` container already exists in production Cosmos.
- If **no existing container**: Just change the definition. `initializeAllContainers()` will create it correctly on next startup.
- If **existing container with `/id`**: You must create a new container `referrals_v2` with `/referrerId`, migrate data, then rename (see Step 1.1b below).
**To check (Azure CLI):**
```bash
az cosmosdb sql container show \
--account-name cosmos-mywisprai \
--database-name lysnrai \
--name referrals \
--resource-group rg-mywisprai \
--query "resource.partitionKey.paths" 2>&1
```
If the container doesn't exist, skip to Step 1.2.
#### Step 1.1b: Container Recreation (only if existing container has wrong PK)
Cosmos DB does not allow changing partition keys on existing containers. If the container exists with `/id`:
```bash
# 1. Export existing data
az cosmosdb sql container show --account-name cosmos-mywisprai \
--database-name lysnrai --name referrals --resource-group rg-mywisprai
# 2. Create new container with correct PK
az cosmosdb sql container create \
--account-name cosmos-mywisprai \
--database-name lysnrai \
--name referrals_v2 \
--partition-key-path "/referrerId" \
--resource-group rg-mywisprai
# 3. Migrate data (use Azure Data Factory or a script)
# See Section 6 for migration script
# 4. Rename containers
# Azure doesn't support rename — create `referrals` with new PK after deleting old
az cosmosdb sql container delete \
--account-name cosmos-mywisprai \
--database-name lysnrai \
--name referrals \
--resource-group rg-mywisprai --yes
az cosmosdb sql container create \
--account-name cosmos-mywisprai \
--database-name lysnrai \
--name referrals \
--partition-key-path "/referrerId" \
--resource-group rg-mywisprai
# 5. Copy data from referrals_v2 → referrals, then delete referrals_v2
```
#### Step 1.2: Add `mindlyst_referrals` container
In `cosmos-init.ts`:
```diff
+ // MindLyst referrals (separate container — different schema from growth/referrals)
+ mindlyst_referrals: { partitionKeyPath: '/userId' },
```
#### Step 1.3: Update MindLyst code
In `mindlyst-native/web/src/lib/cosmos.ts`:
```diff
- { id: "referrals", partitionKey: "/userId" },
+ { id: "mindlyst_referrals", partitionKey: "/userId" },
```
In `mindlyst-native/web/src/app/api/referral/route.ts`:
```diff
- const container = isCosmosConfigured() ? getCosmosContainer("referrals") : null;
+ const container = isCosmosConfigured() ? getCosmosContainer("mindlyst_referrals") : null;
```
#### Step 1.4: Update admin/user dashboard cosmos.ts (alignment)
Both dashboards declare `/referrerId` which is now correct. No change needed. But remove the NOTE comment in `cosmos-init.ts`:
```diff
- // NOTE: MindLyst also uses 'referrals' with /userId partition key, but
- // the growth module already registers it with /id. This mismatch needs
- // a separate migration to reconcile.
```
#### Step 1.5: Verify
```bash
# Platform-service tests
cd learning_ai_common_plat && pnpm test --filter @lysnrai/platform-service
# Admin dashboard typecheck
cd dashboards/admin-web && npx tsc --noEmit
# User dashboard typecheck
cd ../learning_voice_ai_agent/user-dashboard-web && npx tsc --noEmit
# MindLyst typecheck
cd ../learning_multimodal_memory_agents/mindlyst-native/web && npx next build --webpack
```
### Phase 2: API Consolidation (Option C) — Future
**Goal:** MindLyst uses the platform-service referrals API instead of direct Cosmos access.
This requires:
1. **Extend platform-service referrals module** to support MindLyst's link+event model:
- `POST /api/referrals/link` — create referral link (returns code + URL)
- `POST /api/referrals/invite` — track invite event
- `POST /api/referrals/activate` — activate referral
- `GET /api/referrals/stats/:userId` — leaderboard + stats
2. **Add `x-product-id` routing** so the service knows which referral schema/container to use.
3. **MindLyst drops direct Cosmos** for referrals, calls platform-service API via `@bytelyst/api-client`.
4. **Deprecate `mindlyst_referrals` container** once all data flows through the API.
**Timeline:** After MindLyst auth integration with platform-service (Phase 2 in MindLyst roadmap).
---
## 6. Data Migration Script (if needed)
If there is existing data in a `referrals` container with the wrong partition key:
```typescript
/**
* migrate-referrals.ts
* Run with: npx tsx migrate-referrals.ts
*
* Reads all docs from `referrals` (old PK), writes to `referrals_v2` (new PK).
* Validates doc count before and after.
*/
import { CosmosClient } from '@azure/cosmos';
const COSMOS_ENDPOINT = process.env.COSMOS_ENDPOINT!;
const COSMOS_KEY = process.env.COSMOS_KEY!;
const COSMOS_DATABASE = process.env.COSMOS_DATABASE || 'lysnrai';
const client = new CosmosClient({ endpoint: COSMOS_ENDPOINT, key: COSMOS_KEY });
const db = client.database(COSMOS_DATABASE);
async function migrate() {
const source = db.container('referrals');
const target = db.container('referrals_v2');
// 1. Count source docs
const { resources: countRes } = await source.items
.query<number>('SELECT VALUE COUNT(1) FROM c')
.fetchAll();
const totalDocs = countRes[0] ?? 0;
console.log(`Source container has ${totalDocs} documents`);
if (totalDocs === 0) {
console.log('No documents to migrate. Done.');
return;
}
// 2. Read all docs
const { resources: allDocs } = await source.items.query('SELECT * FROM c').fetchAll();
// 3. Write to target (with correct PK field populated)
let migrated = 0;
let skipped = 0;
for (const doc of allDocs) {
// Ensure referrerId exists (required for new PK)
if (!doc.referrerId) {
console.warn(`Skipping doc ${doc.id}: no referrerId field`);
skipped++;
continue;
}
try {
await target.items.create(doc);
migrated++;
} catch (err: any) {
if (err.code === 409) {
console.warn(`Doc ${doc.id} already exists in target, skipping`);
skipped++;
} else {
throw err;
}
}
}
console.log(`Migrated: ${migrated}, Skipped: ${skipped}, Total: ${totalDocs}`);
// 4. Validate target count
const { resources: targetCount } = await target.items
.query<number>('SELECT VALUE COUNT(1) FROM c')
.fetchAll();
console.log(`Target container has ${targetCount[0]} documents`);
if (targetCount[0] !== migrated) {
console.error('WARNING: Count mismatch! Manual verification needed.');
} else {
console.log('Migration validated successfully.');
}
}
migrate().catch(console.error);
```
---
## 7. MindLyst Data Migration (if existing data in wrong container)
If MindLyst documents already exist in a `referrals` container with `/userId` partition key, and we're creating a separate `mindlyst_referrals` container:
```typescript
/**
* migrate-mindlyst-referrals.ts
* Copies MindLyst referral docs (docType: "link"|"event") from `referrals``mindlyst_referrals`.
*/
import { CosmosClient } from '@azure/cosmos';
const client = new CosmosClient({
endpoint: process.env.COSMOS_ENDPOINT!,
key: process.env.COSMOS_KEY!,
});
const db = client.database(process.env.COSMOS_DATABASE || 'lysnrai');
async function migrate() {
const source = db.container('referrals');
const target = db.container('mindlyst_referrals');
// Only migrate MindLyst docs (have productId: "mindlyst" or docType field)
const { resources: docs } = await source.items
.query("SELECT * FROM c WHERE c.productId = 'mindlyst' OR IS_DEFINED(c.docType)")
.fetchAll();
console.log(`Found ${docs.length} MindLyst referral documents to migrate`);
let migrated = 0;
for (const doc of docs) {
try {
await target.items.create(doc);
migrated++;
} catch (err: any) {
if (err.code === 409) continue; // Already exists
throw err;
}
}
console.log(`Migrated ${migrated} documents to mindlyst_referrals`);
// Optionally: delete migrated docs from source
// for (const doc of docs) {
// await source.item(doc.id, doc.userId).delete();
// }
}
migrate().catch(console.error);
```
---
## 8. Rollback Plan
If the migration causes issues:
1. **Phase 1 rollback:** Revert code changes, point MindLyst back to `referrals` container. No data loss since we're copying, not moving.
2. **Container recreation rollback:** Keep `referrals_v2` as backup. If new `referrals` has issues, swap back.
3. **API consolidation rollback (Phase 2):** MindLyst can always fall back to direct Cosmos access by reverting the API client changes.
---
## 9. Pre-Migration Checklist
- [ ] Determine if `referrals` container exists in production Cosmos
- [ ] If yes, check its actual partition key: `az cosmosdb sql container show ...`
- [ ] If yes, count existing documents per productId
- [ ] Backup existing data (export to JSON)
- [ ] Run migration script in staging first
- [ ] Validate document counts match before/after
- [ ] Deploy code changes after data migration completes
- [ ] Run platform-service tests
- [ ] Run dashboard typechecks
- [ ] Verify point reads work in staging
- [ ] Remove old container (only after 7-day validation period)
---
## 10. Files to Modify
### Phase 1 Changes
| Repo | File | Change |
| ------------------- | --------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
| `common_plat` | `services/platform-service/src/lib/cosmos-init.ts` | Change `referrals` PK to `/referrerId`, add `mindlyst_referrals` with `/userId`, remove NOTE comment |
| `common_plat` | `services/platform-service/src/modules/referrals/repository.ts` | No change (already uses `referrerId` correctly) |
| `multimodal_memory` | `mindlyst-native/web/src/lib/cosmos.ts` | Change `referrals``mindlyst_referrals` |
| `multimodal_memory` | `mindlyst-native/web/src/app/api/referral/route.ts` | Change `getCosmosContainer("referrals")``getCosmosContainer("mindlyst_referrals")` |
### No Changes Needed
| Repo | File | Reason |
| ---------------- | ---------------------------------------- | ---------------------------------------------- |
| `common_plat` | `dashboards/admin-web/src/lib/cosmos.ts` | Already declares `/referrerId` ✅ |
| `voice_ai_agent` | `user-dashboard-web/src/lib/cosmos.ts` | Already declares `/referrerId` ✅ |
| `common_plat` | Admin/user dashboard API routes | Use platform-service API, not direct Cosmos ✅ |

View File

@ -98,6 +98,7 @@ const CONTAINER_DEFS: Record<string, ContainerConfig> = {
push_triggers: { partitionKeyPath: '/productId', defaultTtl: 30 * 86400 },
// PeakPulse modules
peak_sessions: { partitionKeyPath: '/userId' },
peak_routes: { partitionKeyPath: '/sessionId' },
// JarvisJr modules (agents, sessions, memory)
jarvis_agents: { partitionKeyPath: '/userId' },
jarvis_sessions: { partitionKeyPath: '/userId' },

View File

@ -0,0 +1,152 @@
/**
* PeakPulse routes module unit tests validates schema parsing and constants.
*/
import { describe, it, expect } from 'vitest';
import { CreatePeakRouteSchema } from './types.js';
// ── CreatePeakRouteSchema ──
describe('CreatePeakRouteSchema', () => {
const validMinimal = {
sessionId: 'ps_abc123',
trackPoints: [
{
timestamp: 1719000000000,
lat: 37.7749,
lon: -122.4194,
altitude: 50,
gpsAltitude: 48,
speedMps: 1.5,
course: 180,
hAccuracy: 5,
vAccuracy: 3,
isPaused: false,
},
],
boundingBox: {
minLat: 37.77,
maxLat: 37.78,
minLon: -122.42,
maxLon: -122.41,
},
};
it('accepts minimal valid input', () => {
const result = CreatePeakRouteSchema.safeParse(validMinimal);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.sessionId).toBe('ps_abc123');
expect(result.data.trackPoints).toHaveLength(1);
expect(result.data.hapticEvents).toEqual([]);
}
});
it('accepts full input with haptic events', () => {
const result = CreatePeakRouteSchema.safeParse({
...validMinimal,
hapticEvents: [
{
timestamp: 1719000060000,
type: 'elevation',
value: 100,
elevationAtEvent: 550,
},
],
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.hapticEvents).toHaveLength(1);
expect(result.data.hapticEvents[0].type).toBe('elevation');
}
});
it('accepts multiple track points', () => {
const points = Array.from({ length: 50 }, (_, i) => ({
timestamp: 1719000000000 + i * 1000,
lat: 37.7749 + i * 0.0001,
lon: -122.4194 + i * 0.0001,
altitude: 50 + i,
gpsAltitude: 48 + i,
speedMps: 1.5 + i * 0.1,
course: (180 + i) % 360,
hAccuracy: 5,
vAccuracy: 3,
isPaused: false,
}));
const result = CreatePeakRouteSchema.safeParse({
...validMinimal,
trackPoints: points,
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.trackPoints).toHaveLength(50);
}
});
it('accepts track points with barometer data', () => {
const result = CreatePeakRouteSchema.safeParse({
...validMinimal,
trackPoints: [
{
...validMinimal.trackPoints[0],
barometerRelativeAltitude: 2.5,
},
],
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.trackPoints[0].barometerRelativeAltitude).toBe(2.5);
}
});
it('rejects missing sessionId', () => {
const noSession = {
trackPoints: validMinimal.trackPoints,
boundingBox: validMinimal.boundingBox,
};
const result = CreatePeakRouteSchema.safeParse(noSession);
expect(result.success).toBe(false);
});
it('rejects empty sessionId', () => {
const result = CreatePeakRouteSchema.safeParse({
...validMinimal,
sessionId: '',
});
expect(result.success).toBe(false);
});
it('rejects invalid latitude in track point', () => {
const result = CreatePeakRouteSchema.safeParse({
...validMinimal,
trackPoints: [{ ...validMinimal.trackPoints[0], lat: 95 }],
});
expect(result.success).toBe(false);
});
it('rejects invalid longitude in track point', () => {
const result = CreatePeakRouteSchema.safeParse({
...validMinimal,
trackPoints: [{ ...validMinimal.trackPoints[0], lon: -200 }],
});
expect(result.success).toBe(false);
});
it('rejects negative speed', () => {
const result = CreatePeakRouteSchema.safeParse({
...validMinimal,
trackPoints: [{ ...validMinimal.trackPoints[0], speedMps: -1 }],
});
expect(result.success).toBe(false);
});
it('rejects invalid bounding box latitude', () => {
const result = CreatePeakRouteSchema.safeParse({
...validMinimal,
boundingBox: { minLat: -100, maxLat: 37.78, minLon: -122.42, maxLon: -122.41 },
});
expect(result.success).toBe(false);
});
});

View File

@ -0,0 +1,45 @@
/**
* PeakPulse routes repository Cosmos DB CRUD for track point data.
*
* Container: peak_routes (partition key: /sessionId)
*/
import { getContainer } from '../../lib/cosmos.js';
import type { PeakRouteDoc } from './types.js';
function container() {
return getContainer('peak_routes');
}
export async function createRoute(doc: PeakRouteDoc): Promise<PeakRouteDoc> {
const { resource } = await container().items.create(doc);
return resource as PeakRouteDoc;
}
export async function getRoute(sessionId: string, routeId: string): Promise<PeakRouteDoc | null> {
try {
const { resource } = await container().item(routeId, sessionId).read<PeakRouteDoc>();
return resource ?? null;
} catch {
return null;
}
}
export async function getRouteBySessionId(sessionId: string): Promise<PeakRouteDoc | null> {
const { resources } = await container()
.items.query<PeakRouteDoc>({
query: 'SELECT * FROM c WHERE c.sessionId = @sessionId',
parameters: [{ name: '@sessionId', value: sessionId }],
})
.fetchAll();
return resources[0] ?? null;
}
export async function deleteRoute(sessionId: string, routeId: string): Promise<boolean> {
try {
await container().item(routeId, sessionId).delete();
return true;
} catch {
return false;
}
}

View File

@ -0,0 +1,74 @@
/**
* PeakPulse routes REST endpoints GPS track point storage.
*
* POST /peak/routes upload track points for a session
* GET /peak/routes/:sessionId get track points for a session
* DELETE /peak/routes/:sessionId delete track points for a session
*/
import type { FastifyInstance } from 'fastify';
import { getRequestProductId } from '../../lib/request-context.js';
import { BadRequestError, NotFoundError } from '../../lib/errors.js';
import { extractAuth } from '../../lib/auth.js';
import * as repo from './repository.js';
import { CreatePeakRouteSchema, type PeakRouteDoc } from './types.js';
export async function peakRouteRoutes(app: FastifyInstance) {
// Get route by session ID
app.get('/peak/routes/:sessionId', async req => {
const auth = await extractAuth(req);
const { sessionId } = req.params as { sessionId: string };
const route = await repo.getRouteBySessionId(sessionId);
if (!route) throw new NotFoundError('Route data not found for session');
if (route.userId !== auth.sub) throw new NotFoundError('Route data not found for session');
return route;
});
// Upload track points
app.post('/peak/routes', async (req, reply) => {
const auth = await extractAuth(req);
const pid = getRequestProductId(req);
const parsed = CreatePeakRouteSchema.safeParse(req.body);
if (!parsed.success) {
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
}
const input = parsed.data;
const now = new Date().toISOString();
const doc: PeakRouteDoc = {
id: `pr_${crypto.randomUUID()}`,
sessionId: input.sessionId,
userId: auth.sub,
productId: pid,
trackPoints: input.trackPoints,
hapticEvents: input.hapticEvents,
trackPointCount: input.trackPoints.length,
hapticEventCount: input.hapticEvents.length,
boundingBox: input.boundingBox,
createdAt: now,
updatedAt: now,
};
req.log.info(
{ routeId: doc.id, sessionId: doc.sessionId, points: doc.trackPointCount },
'Uploading peak route'
);
const created = await repo.createRoute(doc);
reply.code(201);
return created;
});
// Delete route by session ID
app.delete('/peak/routes/:sessionId', async (req, reply) => {
const auth = await extractAuth(req);
const { sessionId } = req.params as { sessionId: string };
const route = await repo.getRouteBySessionId(sessionId);
if (!route) throw new NotFoundError('Route data not found');
if (route.userId !== auth.sub) throw new NotFoundError('Route data not found');
req.log.info({ routeId: route.id, sessionId }, 'Deleting peak route');
const deleted = await repo.deleteRoute(sessionId, route.id);
if (!deleted) throw new NotFoundError('Route delete failed');
reply.code(204);
});
}

View File

@ -0,0 +1,93 @@
/**
* PeakPulse route/track point types GPS track storage for sessions.
*
* Cosmos container: `peak_routes` (partition key: `/sessionId`)
* Product-agnostic: every document includes `productId`.
*/
import { z } from 'zod';
// ── Sub-document interfaces ──
export interface TrackPointDoc {
timestamp: number;
lat: number;
lon: number;
altitude: number;
gpsAltitude: number;
barometerRelativeAltitude?: number;
speedMps: number;
course: number;
hAccuracy: number;
vAccuracy: number;
isPaused: boolean;
}
export interface HapticEventDoc {
timestamp: number;
type: string;
value: number;
elevationAtEvent: number;
}
// ── Main document ──
export interface PeakRouteDoc {
id: string;
sessionId: string;
userId: string;
productId: string;
trackPoints: TrackPointDoc[];
hapticEvents: HapticEventDoc[];
trackPointCount: number;
hapticEventCount: number;
boundingBox: {
minLat: number;
maxLat: number;
minLon: number;
maxLon: number;
};
createdAt: string;
updatedAt: string;
}
// ── Zod schemas ──
const TrackPointSchema = z.object({
timestamp: z.number().int().positive(),
lat: z.number().min(-90).max(90),
lon: z.number().min(-180).max(180),
altitude: z.number(),
gpsAltitude: z.number(),
barometerRelativeAltitude: z.number().optional(),
speedMps: z.number().min(0),
course: z.number().min(-1).max(360),
hAccuracy: z.number().min(0),
vAccuracy: z.number().min(0),
isPaused: z.boolean(),
});
const HapticEventSchema = z.object({
timestamp: z.number().int().positive(),
type: z.string().max(50),
value: z.number(),
elevationAtEvent: z.number(),
});
const BoundingBoxSchema = z.object({
minLat: z.number().min(-90).max(90),
maxLat: z.number().min(-90).max(90),
minLon: z.number().min(-180).max(180),
maxLon: z.number().min(-180).max(180),
});
export const CreatePeakRouteSchema = z.object({
sessionId: z.string().min(1).max(128),
trackPoints: z.array(TrackPointSchema),
hapticEvents: z.array(HapticEventSchema).default([]),
boundingBox: BoundingBoxSchema,
});
// ── Inferred types ──
export type CreatePeakRouteInput = z.infer<typeof CreatePeakRouteSchema>;

View File

@ -76,6 +76,7 @@ import { changelogRoutes } from './modules/changelog/routes.js';
import { pushTriggerRoutes } from './modules/push-triggers/routes.js';
// PeakPulse modules
import { peakSessionRoutes } from './modules/peak-sessions/routes.js';
import { peakRouteRoutes } from './modules/peak-routes/routes.js';
// JarvisJr modules
import { jarvisAgentRoutes } from './modules/jarvis-agents/routes.js';
import { jarvisSessionRoutes } from './modules/jarvis-sessions/routes.js';
@ -199,6 +200,7 @@ await app.register(changelogRoutes, { prefix: '/api' });
await app.register(pushTriggerRoutes, { prefix: '/api' });
// PeakPulse modules
await app.register(peakSessionRoutes, { prefix: '/api' });
await app.register(peakRouteRoutes, { prefix: '/api' });
// JarvisJr modules (agents, sessions, memory)
await app.register(jarvisAgentRoutes, { prefix: '/api' });
await app.register(jarvisSessionRoutes, { prefix: '/api' });