diff --git a/docs/BACKEND_TO_PLATFORM_SERVICE_MIGRATION.md b/docs/BACKEND_TO_PLATFORM_SERVICE_MIGRATION.md new file mode 100644 index 00000000..8af382da --- /dev/null +++ b/docs/BACKEND_TO_PLATFORM_SERVICE_MIGRATION.md @@ -0,0 +1,896 @@ +# Backend → Platform-Service Migration Plan + +> **Status:** Final v4a — ready for implementation +> **Author:** Cascade +> **Date:** 2026-02-15 +> **Repos:** `learning_ai_common_plat` (platform-service), `learning_voice_ai_agent` (LysnrAI) + +--- + +## 1. Goal + +Eliminate duplicated platform logic from the Python backend by having **all clients talk to platform-service directly** for auth, licenses, notifications, usage, settings, and user profiles. The Python backend shrinks to a pure "Dictation API" — only LysnrAI-specific session/transcript/export + OpenAI composition logic remains. + +Additionally, **refactor `productId` from a server-level env var to a request-level value**, enabling a single platform-service instance to serve multiple products (LysnrAI, MindLyst, future products). The combination of `userId + productId` scopes all data. A new **products registry module** gives admins a central place to create and manage products, validate productIds, and store per-product configuration. + +### Before + +``` +iOS / Android / Desktop ──→ Python backend (:8000) ──→ Cosmos DB [auth, license, settings, etc.] +iOS / Android / Desktop ──→ Python backend (:8000) ──→ Cosmos DB [sessions, transcripts] +Web dashboards ──────────→ platform-service (:4003) ──→ Cosmos DB [auth, license, etc.] +Web dashboards ──────────→ Python backend (:8000) ───→ Cosmos DB [sessions, transcripts] + +productId = server-level env var (hardcoded "lysnrai" at startup) +``` + +**Problems:** + +- Auth, license, notifications, usage, settings, users exist in **both** the Python backend and platform-service +- `productId` is hardcoded per deployment — can't serve MindLyst from the same instance +- A user with accounts in both LysnrAI and MindLyst can't be distinguished + +### After + +``` +ALL clients ──→ platform-service (:4003) ──→ Cosmos DB [products, auth, license, notifications, usage, settings] +ALL clients ──→ Python backend (:8000) ───→ Cosmos DB [sessions, transcripts, export] + +productId = from JWT token (authenticated) OR X-Product-Id header (unauthenticated) +``` + +**Python backend routes after migration:** `sessions.py`, `transcripts.py`, `export.py` (3 files) +**Deleted from backend:** `auth.py`, `license.py`, `notifications.py`, `usage.py`, `settings.py`, `users.py`, `email/` (7 files + email dir, ~2,480 lines including tests) + +--- + +## 2. Multi-Product `productId` Design (Foundational Change) + +### 2.0 The Problem + +Today, `PRODUCT_ID` is loaded once at startup from an env var (`loadProductIdentity()` → `"lysnrai"`). Every module imports it and hardcodes it into Cosmos queries and JWT tokens. This means: + +- **One platform-service deployment = one product** — can't serve MindLyst from the same instance +- A user who uses both LysnrAI and MindLyst gets their data mixed (same `userId`, no product scoping at the request level) +- 107 references to `PRODUCT_ID` across 29 files in platform-service + +### 2.1 The Solution: Products Registry + Request-Level `productId` + +**A `products` container in Cosmos DB is the single source of truth for all registered products.** Admin creates a product → gets a `productId` → hands it to the dev team → dev team uses it across the entire stack. + +**`productId` flows from the client on every request:** + +``` +Admin creates product: POST /api/products { productId: "lysnrai", displayName: "LysnrAI", ... } + ↓ +Dev team hardcodes in client: productId = "lysnrai" + ↓ +Login/Register request body: { email, password, productId: "lysnrai" } + ↓ +Platform-service validates: productCache.has("lysnrai") → ✅ (rejects unknown productIds) + ↓ +JWT token payload: { sub, email, role, productId: "lysnrai", type: "access" } + ↓ +All subsequent requests: platform-service reads productId from JWT + ↓ +Cosmos queries: WHERE c.productId = @productId (from JWT, not env var) +``` + +**For unauthenticated requests** (license activation, public roadmap, etc.): + +- Client sends `X-Product-Id: lysnrai` header +- Platform-service reads from header, validates against product registry + +### 2.2 Products Module — Central Product Registry + +**New directory:** `services/platform-service/src/modules/products/` + +``` +products/ +├── types.ts — ProductDoc schema, Zod validation +├── repository.ts — Cosmos CRUD (products container) +├── cache.ts — In-memory Map, refreshed on startup + admin writes +└── routes.ts — CRUD endpoints (admin-only for write, public for read) +``` + +**ProductDoc schema:** + +```typescript +export interface ProductDoc { + id: string; // e.g. "lysnrai" + productId: string; // same as id (partition key) + displayName: string; // e.g. "LysnrAI" + licensePrefix: string; // e.g. "LYSNR" + packageName: string; // e.g. "com.bytelyst.LysnrAI" + defaultPlan: 'free' | 'pro'; // plan assigned on registration + trialDays: number; // e.g. 14 + deviceLimits: { + // max devices per plan + free: number; + pro: number; + enterprise: number; + }; + websiteUrl: string; // e.g. "https://lysnn.com" + status: 'active' | 'disabled'; // disabled products reject all requests + createdAt: string; + updatedAt: string; +} +``` + +**Endpoints:** + +- `GET /api/products` — List all products (public, cached) +- `GET /api/products/:id` — Get one product (public, cached) +- `POST /api/products` — Create product (admin-only) +- `PUT /api/products/:id` — Update product (admin-only, refreshes cache) + +**In-memory cache:** + +```typescript +// products/cache.ts +const productCache = new Map(); + +export async function loadProductCache(): Promise { + const all = await repository.getAll(); + productCache.clear(); + for (const p of all) productCache.set(p.id, p); +} + +export function getProduct(productId: string): ProductDoc | undefined { + return productCache.get(productId); +} + +export function isValidProduct(productId: string): boolean { + return productCache.has(productId); +} +``` + +**Cache is loaded on startup** (`server.ts` calls `loadProductCache()` before listening) and **refreshed on admin writes** (create/update call `loadProductCache()` after mutation). + +**Seed on first startup:** If the products container is empty, seed with LysnrAI defaults from env/config (backward compat). + +### 2.3 Implementation: `getRequestProductId(req)` Helper + +**New file:** `services/platform-service/src/lib/request-context.ts` + +```typescript +import { BadRequestError } from './errors.js'; +import { isValidProduct, getProduct } from '../modules/products/cache.js'; + +/** + * Extract and validate productId from the request. + * Priority: JWT token > X-Product-Id header > env fallback (dev only) + * Rejects unknown or disabled products. + */ +export function getRequestProductId(req: FastifyRequest): string { + // 1. From JWT (set during login/register) + let id = req.jwtPayload?.productId; + + // 2. From header (unauthenticated requests) + if (!id) { + const header = req.headers['x-product-id']; + if (typeof header === 'string' && header.length > 0) id = header; + } + + // 3. Fallback to env var (backward compat during migration, dev only) + if (!id) { + const envFallback = process.env.PRODUCT_ID; + if (envFallback) id = envFallback; + } + + if (!id) throw new BadRequestError('productId is required (via JWT or X-Product-Id header)'); + + // Validate against product registry + if (!isValidProduct(id)) throw new BadRequestError(`Unknown product: ${id}`); + const product = getProduct(id)!; + if (product.status === 'disabled') throw new BadRequestError(`Product ${id} is disabled`); + + return id; +} + +/** Get the full product config for the current request's productId */ +export function getRequestProductConfig(req: FastifyRequest): ProductDoc { + const id = getRequestProductId(req); + return getProduct(id)!; +} +``` + +**Benefits of validation:** + +- Typo like `productId: "lysnraii"` → immediate `400 Bad Request` instead of silently creating orphan data +- Admin can disable a product → all requests for it are rejected instantly +- Modules can read product config (trial days, device limits) without hardcoding + +**Refactor pattern for all 29 files:** + +```typescript +// BEFORE (107 occurrences): +import { PRODUCT_ID } from '../../lib/product-config.js'; +// ... +productId: PRODUCT_ID, + +// AFTER: +import { getRequestProductId } from '../../lib/request-context.js'; +// ... +const productId = getRequestProductId(req); +``` + +### 2.4 JWT Changes + +**File:** `services/platform-service/src/modules/auth/jwt.ts` + +```typescript +// BEFORE: productId from env var +export async function createAccessToken(payload: { sub, email, role }) { + return new SignJWT({ ...payload, productId: PRODUCT_ID, type: 'access' }) + .setIssuer(PRODUCT_ID) // ← hardcoded issuer + +// AFTER: productId from caller (login/register request body) +export async function createAccessToken(payload: { sub, email, role, productId }) { + return new SignJWT({ ...payload, type: 'access' }) + .setIssuer('bytelyst-platform') // ← generic issuer (accepts all products) +``` + +**`verifyToken`** — remove `issuer: PRODUCT_ID` validation (was rejecting tokens from other products): + +```typescript +// BEFORE: +const { payload } = await jwtVerify(token, getSecret(), { issuer: PRODUCT_ID }); + +// AFTER: +const { payload } = await jwtVerify(token, getSecret(), { issuer: 'bytelyst-platform' }); +``` + +### 2.5 Login/Register Schemas Add `productId` + +```typescript +// auth/types.ts +export const LoginSchema = z.object({ + email: z.string().email(), + password: z.string().min(1), + productId: z.string().min(1), // ← NEW: required +}); + +export const RegisterSchema = z.object({ + email: z.string().email(), + password: z.string().min(8), + displayName: z.string().min(1), + role: z.enum(['admin', 'viewer', 'user']).default('user'), + productId: z.string().min(1), // ← NEW: required +}); +``` + +### 2.6 User Identity Model + +User docs are **product-scoped** (matches current `auth/repository.ts` behavior). A user registering for both LysnrAI and MindLyst with the same email gets **two separate user documents** with different `userId`s. See §4.6 for the design rationale. + +``` +users container: { id: "usr_abc", productId: "lysnrai", email: "bob@test.com", ... } + { id: "usr_xyz", productId: "mindlyst", email: "bob@test.com", ... } ← separate user +settings container: { id: "set_lysnrai_usr_abc", productId: "lysnrai", userId: "usr_abc", ... } +licenses container: { id: "lic_xxx", productId: "lysnrai", userId: "usr_abc", plan: "pro" } +subscriptions container: { id: "sub_xxx", productId: "lysnrai", userId: "usr_abc", plan: "pro" } +usage_daily container: { id: "usg_2026-02-15_usr_abc", productId: "lysnrai", userId: "usr_abc" } +``` + +**Key rule:** All containers are scoped by `productId`. Cross-product account linking is a future consideration. + +### 2.7 Migration Path for the 107 References + +The refactor is mechanical but must be done carefully: + +1. **Add products module** — new module, no breakage. Seed with LysnrAI product on startup. +2. **Add `getRequestProductId(req)` helper** — new file, validates against product cache +3. **Add Fastify `onRequest` hook** — parse JWT and attach `productId` to `req.jwtPayload` on every request +4. **Add `productId` to login/register schemas** — clients start sending it +5. **File-by-file refactor** — replace `PRODUCT_ID` import with `getRequestProductId(req)` in routes/repositories +6. **Update repositories to use `getRequestProductConfig()`** — for product-specific values (trial days, device limits) instead of hardcoded constants +7. **Keep `PRODUCT_ID` env var as fallback** — during migration, if no JWT/header, fall back to env var. Remove after all clients are updated. +8. **Update tests** — pass `productId` in test requests + +**Estimated scope:** 29 files, ~107 line changes (mostly mechanical find-replace) + 1 new module (~150 lines). + +--- + +## 3. Contract Parity — Backend vs Platform-Service + +### 3.1 Auth Contract Gaps + +> **Note:** All clients must also start sending `productId` in login/register request bodies (see §2.4). + +| Aspect | Backend (current) | Platform-service (current) | Action | +| ----------------------------- | --------------------------------------------------------------------------------------------- | -------------------------------- | -------------------------------------------------------------------- | +| Field casing | `access_token`, `refresh_token` | `accessToken`, `refreshToken` | **Clients adopt camelCase** | +| User `name` field | `user.name` | `user.displayName` | **Clients adopt `displayName`** | +| User `plan` field | `user.plan` returned | Not returned | **Platform-service adds `plan` to auth responses** | +| `/auth/refresh` response | Returns both `access_token` + `refresh_token` | Returns only `accessToken` | **Platform-service returns both tokens** | +| `/auth/register` side effects | Creates billing subscription (14-day Pro trial) + generates license key + sends welcome email | Just creates user | **Platform-service adds register hook** (see §4.3) | +| `/auth/resend-key` | Exists | Does not exist | **Removed** — admin manually emails license keys via admin dashboard | +| `/auth/me` response | `{id, email, name, role, plan, ...}` | `{id, email, role, displayName}` | **Platform-service adds `plan`** | + +### 3.2 License Contract Gaps + +| Aspect | Backend (current) | Platform-service (current) | Action | +| ----------------------------- | ------------------------------------------------------------------------------- | -------------------------------- | --------------------------------------------------------- | +| URL prefix | `/api/license/` | `/api/licenses/` | **Clients adopt `/licenses/`** | +| `/licenses/activate` response | Returns `{access_token, refresh_token, user_id, plan, device_id}` — issues JWTs | Returns `LicenseDoc` — no JWTs | **Platform-service issues JWTs on activate** | +| Account lockout | IP-based failed attempt tracking | Not implemented | **Platform-service adds lockout** (use rate-limit module) | +| `/license/refresh` endpoint | Separate refresh for license-based sessions | Not needed — use `/auth/refresh` | **Clients switch to `/auth/refresh`** | +| `/license/status` response | Includes device list + usage | Simpler status only | **Platform-service enriches response** | + +### 3.3 Notifications — Already Aligned + +Backend routes are already marked `deprecated=True` with comments saying "Dashboard clients now use platform-service". The backend just proxies to Cosmos DB with the same schema. Platform-service `modules/notifications/` is the source of truth. + +**Action:** Delete backend `notifications.py`. Update iOS/Desktop to call platform-service directly for device registration and notification preferences. + +### 3.4 Usage — Already Aligned + +Backend routes are already marked `deprecated=True` with comments saying "These routes duplicate the Billing Service". Platform-service `modules/usage/` has full parity (list, summary, upsert, check-limits). + +**Action:** Delete backend `usage.py`. Update Desktop `api_sync.py` to call platform-service. + +### 3.5 Settings — Needs New Module + +Backend `settings.py` (170 lines) provides user settings CRUD with per-device overrides. **No equivalent exists in platform-service.** + +**Current backend settings model** (`userId + productId` scoped): + +- Global settings: hotkey, language, cleanup, sound, clipboard, custom vocabulary/instructions, template +- Per-device overrides: same fields, merged on read +- Doc ID: `set_{userId}` → changes to `set_{productId}_{userId}` + +**Action:** Create `modules/settings/` in platform-service. Product-agnostic — each product stores its own settings schema per user. The settings "shape" (which keys exist, defaults) is product-defined, but the CRUD logic (get, put, device overrides) is shared. + +Settings doc structure: + +```json +{ + "id": "set_lysnrai_usr_abc", + "productId": "lysnrai", + "userId": "usr_abc", + "settings": { + /* product-specific key-value pairs */ + }, + "deviceOverrides": { + "device123": { + /* partial overrides */ + } + }, + "updatedAt": "..." +} +``` + +Platform-service endpoints: + +- `GET /api/settings` — get settings (productId from JWT) +- `PUT /api/settings` — update settings (merge) +- `GET /api/settings/device/:deviceId` — get resolved settings for device +- `PUT /api/settings/device/:deviceId` — set device overrides +- `DELETE /api/settings/device/:deviceId` — clear device overrides + +### 3.6 Users — Merge into Auth Module + +Backend `users.py` (82 lines) has one endpoint: `PUT /users/:userId/plan` — updates a user's plan tier. This is called by Stripe webhooks after payment events. + +**Action:** Add `PUT /auth/users/:userId/plan` to the auth module (or keep it internal via `X-Internal-Key`). The user profile (displayName, email, etc.) is already managed by the auth module. Plan field is being added to UserDoc (§4.1). This endpoint just needs to exist for Stripe webhook callbacks. + +Platform-service already has `modules/stripe/routes.ts` which can call the auth repository directly — so the `users.py` endpoint may not even be needed as a REST route. The Stripe webhook handler can update the user plan inline. + +--- + +## 4. Platform-Service Changes (common-plat repo) + +### 4.0 Products Module (NEW) + +**New directory:** `services/platform-service/src/modules/products/` (see §2.2 for full design) + +This module is the **first thing built** in Phase 0 because `getRequestProductId()` depends on the product cache for validation. + +**Key behaviors:** + +- On startup: load all products into memory cache +- On admin create/update: refresh cache +- Seed on empty: if products container has 0 docs, create LysnrAI with defaults from env +- Register in `server.ts`: `await app.register(productRoutes, { prefix: '/api' })` (before other modules) +- Create `products` container in Cosmos DB initialization + +**How other modules use product config:** + +```typescript +// licenses/routes.ts — device limit check +import { getRequestProductConfig } from '../../lib/request-context.js'; + +const product = getRequestProductConfig(req); +const maxDevices = product.deviceLimits[license.plan]; // not hardcoded + +// auth/routes.ts — register hook +const product = getRequestProductConfig(req); +await createSubscription({ plan: product.defaultPlan, trialDays: product.trialDays, ... }); +``` + +### 4.1 Auth Module Enhancements + +**File:** `services/platform-service/src/modules/auth/` + +1. **Add `plan` to UserDoc** — Store `plan` field (default `'free'`). Return it in login, register, me, and refresh responses. + +2. **`/auth/refresh` returns both tokens** — Currently returns only `{accessToken}`. Change to return `{accessToken, refreshToken}` (new refresh token issued each time — rotation pattern). + +3. **`/auth/me` adds `plan`** — Return `{id, email, role, displayName, plan}`. + +### 4.2 License Module Enhancements + +**File:** `services/platform-service/src/modules/licenses/` + +1. **`/licenses/activate` issues JWTs** — After validating license + registering device, create JWT access + refresh tokens. Response becomes: + + ```json + { + "accessToken": "...", + "refreshToken": "...", + "userId": "usr_...", + "plan": "pro", + "deviceId": "abc123" + } + ``` + +2. **Add IP-based lockout to activate** — Use Fastify `request.ip`. Track failed attempts in-memory (same pattern as backend). After 5 failures in 5 minutes → 15-minute lockout. + +3. **Enrich `/licenses/status/:key`** — Include device list and usage summary in response. + +### 4.3 Register Hook — Subscription + License (Log-Only) + +**File:** `services/platform-service/src/modules/auth/routes.ts` (register endpoint) + +After creating the user, read product config and add best-effort post-registration steps: + +``` +1. Read product config → products cache (trialDays, defaultPlan, licensePrefix) +2. Create subscription (product.trialDays trial, product.defaultPlan) → subscriptions module +3. Generate license key (product.licensePrefix) → licenses module +4. Log registration event (userId, email, licenseKey, plan, productId) → structured log +``` + +Steps 2–3 are **best-effort** — failures are logged but don't block registration. The license key is returned in the register response so the client can display it immediately. + +### 4.4 No Email Module — Log-Only + Admin Manual Flow + +Email sending is **removed entirely**. The backend's email module (`backend/src/email/`) was never wired up (no `azure-communication-email` in requirements.txt, no `AZURE_EMAIL_CONNECTION_STRING` configured). No emails were ever actually sent. + +**Post-registration flow:** + +1. Platform-service creates user + generates license key + creates subscription +2. All registration details are **logged** (structured log: userId, email, licenseKey, plan) +3. License key is returned in the register API response +4. Admin views new registrations in the **admin dashboard** (users list page) +5. Admin copies the license key and emails the user manually from their Gmail/personal email + +**Why this is fine for pre-launch / early beta:** + +- Low volume — admin can handle manual emails +- No email infrastructure to set up, configure, or pay for +- No email deliverability issues (SPF, DKIM, spam filters) +- Can add automated email later as a separate feature when volume demands it + +**`/auth/resend-key` endpoint:** Not needed. Admin looks up the license key in the admin dashboard and emails it manually. + +### 4.5 BILLING_INTERNAL_KEY Guard — Must Be Removed for Direct Client Access + +**Critical gap:** `server.ts` currently wraps usage, subscriptions, plans, and licenses behind a `BILLING_INTERNAL_KEY` guard (lines 76–94). When `BILLING_INTERNAL_KEY` is set, these routes require `X-Internal-Key` header — which mobile/desktop clients can't provide. + +**After migration, mobile/desktop clients call these endpoints directly.** The guard must be removed or replaced with standard JWT auth. + +**Action:** Remove the `billingScope` wrapper. All these routes should use normal JWT auth (Bearer token), same as auth routes. The `X-Internal-Key` pattern was a legacy from when billing-service was separate. + +### 4.6 User Doc: Product-Scoped (Design Decision) + +**Current state:** `auth/repository.ts` queries users with `WHERE c.productId = @productId AND c.email = @email`. This means user docs are **product-scoped** — a user registering for both LysnrAI and MindLyst gets **two separate user documents**. + +**Decision: Keep product-scoped users.** This matches the existing code and is the simplest model. Reasons: + +- Simpler — no cross-product account linking logic needed +- Each product manages its own users independently +- If a user has both LysnrAI and MindLyst accounts with the same email, they're treated as separate users +- Cross-product account linking ("single sign-on across products") can be added later when there's actual demand + +**Updated §2.5 user model:** + +``` +users container: { id: "usr_abc", productId: "lysnrai", email: "bob@test.com", ... } + { id: "usr_xyz", productId: "mindlyst", email: "bob@test.com", ... } ← separate user +settings container: { id: "set_lysnrai_usr_abc", productId: "lysnrai", userId: "usr_abc", ... } +licenses container: { id: "lic_xxx", productId: "lysnrai", userId: "usr_abc", plan: "pro" } +``` + +### 4.7 Tests + +- **Products:** CRUD tests, cache invalidation on write, seed-on-empty behavior, unknown productId rejection +- **Auth:** Update existing tests for `plan` in responses, refresh returning both tokens, `productId` in requests +- **Licenses:** Add test for JWT issuance on activate, lockout behavior, device limit from product config +- **Settings:** New module tests (CRUD, device overrides, productId scoping) +- **Register hook:** Test subscription + license are called with product config values (mocked) + +--- + +## 5. Client Changes (LysnrAI repo) + +### 5.1 iOS — `AuthService.swift` + +**File:** `mobile_app/ios/LysnrAI/Auth/AuthService.swift` + +| Change | Before | After | +| ------------------ | -------------------------------------- | -------------------------------------------------------------------------- | +| `baseURL` for auth | `http://127.0.0.1:8000` | Read `PLATFORM_SERVICE_URL` from `env.dev` | +| `TokenResponse` | `access_token`, `refresh_token` | `accessToken`, `refreshToken` | +| `AuthUser` | `name: String`, `plan: String` | `displayName: String`, `plan: String` | +| `RefreshResponse` | `access_token`, `refresh_token` | `accessToken`, `refreshToken` | +| Refresh body key | `"refresh_token"` | `"refreshToken"` | +| Login body | `{email, password}` | `{email, password, productId: "lysnrai"}` | +| Register body | `{email, name, password}` | `{email, displayName, password, productId: "lysnrai"}` | +| Register response | `UserResponse` (then auto-login) | `{accessToken, refreshToken, user, licenseKey}` — no separate login needed | +| Settings | Reads from `API_BASE_URL/api/settings` | Reads from `PLATFORM_SERVICE_URL/api/settings` | + +**Note:** iOS still calls the Python backend for sessions/transcripts (different base URL). Two base URLs: + +- `PLATFORM_SERVICE_URL` → auth, license, notifications, usage, settings +- `API_BASE_URL` → sessions, transcripts, export + +### 5.2 Android — `AuthViewModel.kt` + +**File:** `mobile_app/android/app/src/main/java/com/saravana/lysnrai/ui/auth/AuthViewModel.kt` + +Same field renames as iOS. Point auth calls to `PLATFORM_SERVICE_URL`. Add `productId: "lysnrai"` to login/register bodies. + +### 5.3 Desktop — Python clients + +Desktop clients currently read `LYSNR_API_URL` env var (defaults to `http://localhost:8000/api`). After migration, they need **two** URLs: + +| Env Var | Default (dev) | Used For | +| -------------------- | --------------------------- | ------------------------------------------------- | +| `LYSNR_API_URL` | `http://localhost:8000/api` | Sessions, transcripts, export (Python backend) | +| `LYSNR_PLATFORM_URL` | `http://localhost:4003/api` | Auth, license, settings, usage (platform-service) | + +| File | Change | +| --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `src/auth/auth_client.py` | Add `_get_platform_url()` reading `LYSNR_PLATFORM_URL`. All auth calls use platform URL. Fields: `accessToken`, `refreshToken`, `displayName`. Add `productId: "lysnrai"` to login/register bodies. | +| `src/licensing/license_client.py` | Same: use `LYSNR_PLATFORM_URL`. Path: `/api/licenses/activate` (not `/api/license/activate`). Response fields: camelCase. Add `X-Product-Id: lysnrai` header (unauthenticated). Remove `/license/refresh` — use `/auth/refresh`. | +| `src/cloud/api_sync.py` | `report_usage()` → use `LYSNR_PLATFORM_URL` for `/api/usage`. Transcript sync stays on `LYSNR_API_URL`. | + +### 5.4 Env Config Updates + +**`mobile_app/ios/env.dev.example`:** + +``` +API_BASE_URL=http://127.0.0.1:8000 +PLATFORM_SERVICE_URL=http://127.0.0.1:4003 +``` + +**`mobile_app/android/env.dev.example`:** + +``` +API_BASE_URL=http://10.0.2.2:8000 +PLATFORM_SERVICE_URL=http://10.0.2.2:4003 +``` + +**Desktop `~/.LysnrAI/.env` and `.env.example`:** + +``` +LYSNR_API_URL=http://localhost:8000/api +LYSNR_PLATFORM_URL=http://localhost:4003/api +``` + +### 5.5 Environment Detection — Corp (localhost) vs Production (cloud) + +**The problem:** The Mac desktop app runs in two contexts: + +1. **Corporate dev (your Mac)** — both services on localhost, behind Forcepoint proxy +2. **Production (end-user Macs)** — services hosted on Azure, accessed over public internet + +The desktop app must know which URLs to hit without the user manually editing config files. + +**Solution: Build-time environment baking + runtime override** + +``` +┌──────────────────────────────────────────────────────────────┐ +│ URL Resolution Order (desktop app) │ +│ │ +│ 1. ~/.LysnrAI/.env (user override — always wins) │ +│ 2. Environment variable (LYSNR_PLATFORM_URL, LYSNR_API_URL)│ +│ 3. Built-in defaults (baked at build/release time) │ +└──────────────────────────────────────────────────────────────┘ +``` + +**Development (corp Mac):** + +- `~/.LysnrAI/.env` has `LYSNR_PLATFORM_URL=http://localhost:4003/api` +- `~/.LysnrAI/.env` has `LYSNR_API_URL=http://localhost:8000/api` +- Both services run locally, only outbound Azure calls go through proxy +- Developer runs `run-local-all-services.sh` → everything works + +**Production (release build):** + +- Built-in defaults in `src/auth/auth_client.py` and `src/licensing/license_client.py` change to: + ```python + _DEFAULT_PLATFORM_URL = "https://platform.bytelyst.com/api" # Azure Container Apps + _DEFAULT_API_URL = "https://api.lysnrai.com/api" # Azure Container Apps + ``` +- End users don't need `~/.LysnrAI/.env` for URLs — defaults just work +- Power users can override with `~/.LysnrAI/.env` if needed (e.g., self-hosted) + +**Build process:** + +- `scripts/build-desktop.sh` takes a `--env` flag: `dev` (localhost defaults) or `prod` (cloud defaults) +- The build script patches the default URLs in the Python source before packaging +- Or simpler: ship a `defaults.env` inside the app bundle that the config loader reads + +**iOS / Android:** + +- Same pattern: `env.dev` (localhost) vs `env.prod` (cloud URLs) +- Xcode schemes / Gradle build flavors select the right env file +- Already standard mobile practice + +**Key principle:** Developers override locally via `~/.LysnrAI/.env`. Released apps ship with production URLs baked in. No config needed for end users. + +--- + +## 6. Backend Cleanup (LysnrAI repo) + +### 6.1 Delete Routes + +| File | Lines | Reason | +| ------------------------------------- | ----- | --------------------------------------------------------- | +| `backend/src/routes/auth.py` | 264 | Moved to platform-service | +| `backend/src/routes/license.py` | 436 | Moved to platform-service | +| `backend/src/routes/notifications.py` | 149 | Moved to platform-service | +| `backend/src/routes/usage.py` | 76 | Moved to platform-service | +| `backend/src/routes/settings.py` | 170 | Moved to platform-service | +| `backend/src/routes/users.py` | 82 | Moved to platform-service (Stripe webhook handles inline) | +| `backend/src/email/service.py` | 92 | Removed (was never functional) | +| `backend/src/email/templates.py` | 134 | Removed (was never functional) | + +### 6.2 Update `main.py` + +Remove router registrations: + +```python +# DELETE these lines: +app.include_router(auth.router, prefix=prefix) +app.include_router(license.router, prefix=prefix) +app.include_router(notifications.router, prefix=prefix) +app.include_router(usage.router, prefix=prefix) +app.include_router(settings.router, prefix=prefix) +app.include_router(users.router, prefix=prefix) +``` + +Remove imports: + +```python +# DELETE from imports: +auth, license, notifications, settings, usage, users +``` + +### 6.3 Delete/Update Tests + +| File | Action | +| ------------------------------------ | ------------------------------------------- | +| `backend/tests/test_auth.py` | Delete (auth now in platform-service tests) | +| `backend/tests/test_license.py` | Delete | +| `backend/tests/test_settings.py` | Delete | +| `backend/tests/test_usage.py` | Delete | +| `backend/tests/test_billing_flow.py` | Review — may reference auth endpoints | + +### 6.4 Clean Up Dead Code + +- `backend/src/auth/jwt.py` — Keep if sessions/transcripts still need `get_current_user`. The JWT verification logic stays (backend still validates tokens), but token **issuance** (`create_access_token`, `create_refresh_token`) is no longer needed. +- `backend/src/auth/password.py` — Delete (password hashing moved to platform-service). +- `backend/src/models/user.py` — Keep `UserInDB` (used by `get_current_user` dep). Remove `UserCreate`, `UserResponse`. +- `backend/src/clients/platform_client.py` — Remove notification and audit methods (dead after route deletion). Keep if other routes still use it. +- `backend/src/clients/billing_client.py` — Review if still needed (register was the main consumer). +- `backend/src/middleware/rate_limit.py` — Review — may reference auth/license routes. +- `backend/src/middleware/usage_limits.py` — Review — was used by license status route. + +### 6.5 JWT Shared Secret + +**Critical:** Platform-service and Python backend must share the same `JWT_SECRET` so tokens issued by platform-service are valid when the backend verifies them for session/transcript endpoints. + +Both already read from `JWT_SECRET` env var / Azure Key Vault (`lysnr-jwt-secret`). No code change needed, just ensure deployment config is aligned. + +**Note on issuer:** The backend's `decode_token()` in `backend/src/auth/jwt.py` does **NOT** validate the JWT issuer field — it only verifies the signature and expiration. So when platform-service changes its issuer from `'lysnrai'` to `'bytelyst-platform'`, the backend is **unaffected**. No backend JWT code change needed for the issuer change. + +--- + +## 7. Networking & Deployment Topology + +### 7.1 Corporate Proxy Constraint + +The development Mac sits behind a **Forcepoint corporate proxy** that only allows Azure-bound traffic. This affects the architecture: + +``` +✅ localhost → Azure Cosmos DB (proxy allows) +✅ localhost → Azure OpenAI (proxy allows) +✅ localhost → Azure Blob Storage (proxy allows) +❌ localhost → external SMTP server (proxy BLOCKS) +❌ localhost → non-Azure external APIs (proxy BLOCKS) +``` + +**Impact:** All outbound calls from both services are Azure-bound → proxy-safe. No email module needed. + +### 7.2 Local Development Topology + +Both services run on localhost. The desktop app, iOS Simulator, and web dashboards all connect locally: + +``` +Desktop app (Python) ──→ localhost:4003 (platform-service) [auth, license, settings, usage] +Desktop app (Python) ──→ localhost:8000 (Python backend) [sessions, transcripts, export] +iOS Simulator ──→ 127.0.0.1:4003 [auth, license, settings, usage] +iOS Simulator ──→ 127.0.0.1:8000 [sessions, transcripts, export] +Web dashboards ──→ localhost:4003 [via @bytelyst/* packages] +Web dashboards ──→ localhost:8000 [sessions, transcripts API routes] + +platform-service (:4003) ──→ Azure Cosmos DB (outbound, proxy-safe) +Python backend (:8000) ──→ Azure Cosmos DB (outbound, proxy-safe) +Python backend (:8000) ──→ Azure OpenAI (outbound, proxy-safe) +``` + +**Startup:** `run-local-all-services.sh` starts both services. No changes needed to the dev workflow. + +### 7.3 Production Topology (Azure) + +``` +Mobile / Desktop ──→ https://platform.bytelyst.com (Azure Container Apps — platform-service) +Mobile / Desktop ──→ https://api.lysnrai.com (Azure Container Apps — Python backend) +Web dashboards ──→ Azure Static Web Apps / App Service [SSR + API routes] + +All services ──→ Azure Cosmos DB (private VNet) +Backend ──→ Azure OpenAI (private endpoint) +``` + +**Production URLs (pending domain setup):** + +- `platform.bytelyst.com` — shared across all products (LysnrAI, MindLyst, future) +- `api.lysnrai.com` — LysnrAI-specific dictation API (sessions, transcripts, export) +- Custom domains on Azure Container Apps with managed SSL certificates + +No proxy issues in production — all Azure-to-Azure traffic. + +### 7.4 JWT Cross-Service Validation + +**Critical for the two-service localhost setup:** + +Platform-service issues JWTs → Desktop/iOS sends them to Python backend for session/transcript calls → Backend must accept them. + +Requirements: + +- **Same `JWT_SECRET`** — both services read from the same env var / Key Vault secret (`lysnr-jwt-secret`) +- **Same algorithm** — both use HS256 +- **Issuer — not a concern** — backend's `decode_token()` does not validate the issuer field, only signature + expiration + +**Backend change needed:** None. Tokens from platform-service (with any issuer) are accepted as long as the `JWT_SECRET` matches. + +--- + +## 8. Execution Order + +### Phase 0: Products module + productId refactor — foundational (common-plat) + +This phase must come first because everything else builds on the product registry and request-level `productId`. + +``` +Commit 1: feat(platform-service): add products module (types, repository, cache, routes) + — Create products container in Cosmos, seed with LysnrAI defaults +Commit 2: feat(platform-service): add getRequestProductId() + getRequestProductConfig() helpers + — Validates productId against product cache, rejects unknown/disabled products +Commit 3: feat(platform-service): add Fastify onRequest hook to parse JWT → req.jwtPayload +Commit 4: refactor(platform-service): auth/jwt.ts — productId from caller, issuer → 'bytelyst-platform' +Commit 5: refactor(platform-service): auth routes + types — add productId to login/register schemas +Commit 6: refactor(platform-service): replace PRODUCT_ID with getRequestProductId(req) in all modules + (29 files, ~107 changes — mechanical find-replace, done in batches) +Commit 7: refactor(platform-service): remove BILLING_INTERNAL_KEY guard from server.ts + — All billing-scope routes now use standard JWT auth +Commit 8: test(platform-service): update all tests to pass productId in requests + product tests +``` + +**Verification:** `pnpm test` — all 158+ tests pass with request-level productId. + +### Phase 1: Platform-service feature additions (common-plat) + +No email module — registration logs events for admin to act on manually. + +``` +Commit 9: feat(platform-service): add plan field to auth UserDoc + all auth responses +Commit 10: feat(platform-service): auth/refresh returns both accessToken + refreshToken +Commit 11: feat(platform-service): licenses/activate issues JWT tokens + IP lockout + — Device limits read from product config, not hardcoded +Commit 12: feat(platform-service): register hook — subscription + license (reads trialDays/plan from product config) +Commit 13: feat(platform-service): add settings module (userId + productId scoped, device overrides) +Commit 14: feat(platform-service): add user plan update to auth module (for Stripe webhooks) +Commit 15: test(platform-service): add tests for all new functionality +``` + +**Verification:** `pnpm test` — all tests pass. + +### Phase 2: Client migration (LysnrAI) + +``` +Commit 16: refactor(ios): AuthService → platform-service (camelCase, displayName, productId, PLATFORM_SERVICE_URL) +Commit 17: refactor(android): AuthViewModel → platform-service (same changes) +Commit 18: refactor(desktop): auth_client + license_client + api_sync → platform-service +Commit 19: chore: update env.dev.example files with PLATFORM_SERVICE_URL +``` + +**Verification:** iOS builds (`xcodebuild`), Desktop tests (`pytest tests/`). + +### Phase 3: Backend cleanup (LysnrAI) + +``` +Commit 20: refactor(backend): delete auth, license, notifications, usage, settings, users routes + email/ +Commit 21: refactor(backend): clean up dead models, tests, middleware, clients +Commit 22: chore(backend): update main.py, .env.example, requirements.txt +``` + +**Verification:** `python -m pytest tests/ backend/tests/ -v --tb=short` + +### Phase 4: Cross-repo verification + +``` +- Platform-service: pnpm test (170+ tests expected) +- Backend: pytest (remaining session/transcript/export tests) +- Admin dashboard: npx tsc --noEmit +- User dashboard: npx tsc --noEmit +- Tracker dashboard: npx tsc --noEmit +- Local smoke test: start both services, login via desktop app, create session, verify JWT cross-service flow +``` + +--- + +## 9. Risk Mitigation + +| Risk | Mitigation | +| ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| JWT secret mismatch between services | Both read `JWT_SECRET` from same Key Vault secret (`lysnr-jwt-secret`). Verify in deployment config. | +| JWT issuer change | Platform-service changes issuer to `'bytelyst-platform'`. Backend is unaffected — its `decode_token()` doesn't validate issuer. | +| Mobile app can't reach platform-service | `PLATFORM_SERVICE_URL` is a runtime config (env.dev), not compiled. Easy to change without app rebuild. | +| No automated emails | By design — admin manually emails users from admin dashboard. Automated email can be added later when volume demands it. | +| Unknown productId silently creates orphan data | Product registry validates every `productId` against Cosmos `products` container. Unknown IDs → 400 Bad Request. | +| productId refactor breaks existing web dashboards | Phase 0 keeps `PRODUCT_ID` env-var as fallback. Dashboards have their own `product-config.ts` that reads `PRODUCT_ID` for direct Cosmos queries (e.g., `repositories/users.ts`). These keep working unchanged — they use env-var for their own queries, and send it via `@bytelyst/*` client packages when calling platform-service. Fallback removed only after all clients are updated. | +| 107 PRODUCT_ID references — regression risk | Mechanical refactor done file-by-file. Each batch is a separate commit. Tests run after each batch. | +| Breaking existing platform-service tests | Phase 0 Commit 8 updates all test fixtures to include `productId`. Run tests after each commit. | +| Product cache stale after admin update | Cache is refreshed after every admin write (create/update). Cache miss falls through to direct Cosmos read. | + +--- + +## 10. What the Backend Becomes (Post-Migration) + +**3 route files (down from 13):** + +| Route | Purpose | Stays because | +| ------------------------ | ------------------------------------------------------------ | --------------------------------------------------------- | +| `sessions.py` (19KB) | Dictation sessions CRUD, AI composition, SSE streaming, sync | LysnrAI-specific product logic + Azure OpenAI integration | +| `transcripts.py` (8.8KB) | Voice transcript storage, search | LysnrAI-specific product logic | +| `export.py` (3.1KB) | Session/transcript data export | Tied to product data models | + +**Remaining support code:** + +- `auth/deps.py` — `get_current_user` JWT verification (validates tokens from platform-service, doesn't issue them) +- `clients/openai_client.py` — Azure OpenAI for session composition +- `cloud/cosmos.py` — Cosmos DB client for sessions + transcripts containers only +- `config.py` — Backend-specific settings (OpenAI endpoint/key, Cosmos, CORS) +- `middleware/` — Request ID, CORS + +**Lines of code removed:** ~1,400 (routes) + ~230 (email) + ~170 (settings) + ~80 (users) + ~600 (tests) = **~2,480 lines deleted** + +**The Python backend becomes a minimal "Dictation API"** — 3 route files, ~31KB of product logic, no auth/platform concerns. + +--- + +## 11. Future Considerations + +- **`organizations.py`** (479 lines) — Product-agnostic team management. Move to platform-service. +- **`webhooks.py`** (256 lines) — Event dispatch. Move to platform-service. +- **`api_tokens.py`** (3.6KB) — API key CRUD. Move to platform-service. +- **`themes.py`** (4.4KB) — UI theme preferences. Move to platform-service (partially exists in admin-dashboard already). +- **Products module extensions** — Product-level feature flags (enable/disable features per product), branding config (logo, colors for white-label), webhook URLs per product, rate limit config per product. +- **Cross-product SSO** — If users want one login for all ByteLyst products, add account linking (shared email → linked userId across products). Not needed pre-launch. +- **Automated email** — When user volume outgrows manual admin emails, add Azure Communication Services or SendGrid module. Products table can store sender email and template overrides. +- **Eventually the Python backend could become an Azure Function** — just sessions + transcripts + OpenAI, no persistent server needed. Cold start is acceptable for dictation workloads. diff --git a/services/platform-service/src/lib/webhooks.ts b/services/platform-service/src/lib/webhooks.ts index 087039e3..683a3014 100644 --- a/services/platform-service/src/lib/webhooks.ts +++ b/services/platform-service/src/lib/webhooks.ts @@ -8,7 +8,7 @@ * Payloads are JSON; failures are logged but never block the caller. */ -import { PRODUCT_ID } from './product-config.js'; +import { DEFAULT_PRODUCT_ID } from './product-config.js'; export interface WebhookPayload { event: string; @@ -24,13 +24,14 @@ export interface WebhookPayload { export async function dispatchWebhook( url: string | undefined, event: string, - data: Record + data: Record, + productId?: string ): Promise { if (!url) return false; const payload: WebhookPayload = { event, - productId: PRODUCT_ID, + productId: productId ?? DEFAULT_PRODUCT_ID, timestamp: new Date().toISOString(), data, }; diff --git a/services/platform-service/src/modules/audit/repository.ts b/services/platform-service/src/modules/audit/repository.ts index 00745e13..0badbf15 100644 --- a/services/platform-service/src/modules/audit/repository.ts +++ b/services/platform-service/src/modules/audit/repository.ts @@ -3,7 +3,6 @@ */ import { getContainer } from '../../lib/cosmos.js'; -import { PRODUCT_ID } from '../../lib/product-config.js'; import type { AuditDoc, QueryAuditInput } from './types.js'; // Default TTL: 90 days in seconds @@ -21,13 +20,13 @@ export async function create(doc: AuditDoc): Promise { return resource as AuditDoc; } -export async function query(input: QueryAuditInput): Promise { +export async function query(input: QueryAuditInput, productId: string): Promise { const { userId, action, category, days, limit, offset } = input; const since = new Date(Date.now() - days * 86400000).toISOString(); let queryText = 'SELECT * FROM c WHERE c.productId = @productId AND c.createdAt >= @since'; const parameters: { name: string; value: string | number }[] = [ - { name: '@productId', value: PRODUCT_ID }, + { name: '@productId', value: productId }, { name: '@since', value: since }, ]; @@ -54,14 +53,14 @@ export async function query(input: QueryAuditInput): Promise { return resources; } -export async function getStats(days = 30): Promise> { +export async function getStats(days = 30, productId?: string): Promise> { const since = new Date(Date.now() - days * 86400000).toISOString(); const { resources } = await container() .items.query<{ action: string; count: number }>({ query: 'SELECT c.action, COUNT(1) as count FROM c WHERE c.productId = @productId AND c.createdAt >= @since GROUP BY c.action', parameters: [ - { name: '@productId', value: PRODUCT_ID }, + { name: '@productId', value: productId ?? '' }, { name: '@since', value: since }, ], }) diff --git a/services/platform-service/src/modules/audit/routes.ts b/services/platform-service/src/modules/audit/routes.ts index 9b25768a..898770ba 100644 --- a/services/platform-service/src/modules/audit/routes.ts +++ b/services/platform-service/src/modules/audit/routes.ts @@ -7,7 +7,7 @@ */ import type { FastifyInstance } from 'fastify'; -import { PRODUCT_ID } from '../../lib/product-config.js'; +import { getRequestProductId } from '../../lib/request-context.js'; import { BadRequestError } from '../../lib/errors.js'; import * as repo from './repository.js'; import { CreateAuditSchema, QueryAuditSchema, type AuditDoc } from './types.js'; @@ -20,9 +20,10 @@ export async function auditRoutes(app: FastifyInstance) { throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); } const input = parsed.data; + const productId = getRequestProductId(req); const doc: AuditDoc = { id: `aud_${crypto.randomUUID()}`, - productId: PRODUCT_ID, + productId, ...input, createdAt: new Date().toISOString(), }; @@ -46,14 +47,16 @@ export async function auditRoutes(app: FastifyInstance) { if (!parsed.success) { throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); } - const records = await repo.query(parsed.data); + const productId = getRequestProductId(req); + const records = await repo.query(parsed.data, productId); return { records, count: records.length }; }); // Stats app.get('/audit/stats', async req => { const { days = '30' } = req.query as { days?: string }; - const stats = await repo.getStats(Number(days)); + const productId = getRequestProductId(req); + const stats = await repo.getStats(Number(days), productId); return { stats, days: Number(days) }; }); } diff --git a/services/platform-service/src/modules/auth/repository.ts b/services/platform-service/src/modules/auth/repository.ts index fb4ebc8d..d7e0c420 100644 --- a/services/platform-service/src/modules/auth/repository.ts +++ b/services/platform-service/src/modules/auth/repository.ts @@ -4,19 +4,18 @@ import bcrypt from 'bcryptjs'; import { getContainer } from '../../lib/cosmos.js'; -import { PRODUCT_ID } from '../../lib/product-config.js'; import type { UserDoc } from './types.js'; function container() { return getContainer('users'); } -export async function getByEmail(email: string): Promise { +export async function getByEmail(email: string, productId: string): Promise { const { resources } = await container() .items.query({ query: 'SELECT * FROM c WHERE c.productId = @productId AND c.email = @email', parameters: [ - { name: '@productId', value: PRODUCT_ID }, + { name: '@productId', value: productId }, { name: '@email', value: email.toLowerCase() }, ], }) diff --git a/services/platform-service/src/modules/auth/routes.ts b/services/platform-service/src/modules/auth/routes.ts index 03509bc3..e9779c52 100644 --- a/services/platform-service/src/modules/auth/routes.ts +++ b/services/platform-service/src/modules/auth/routes.ts @@ -22,7 +22,7 @@ export async function authRoutes(app: FastifyInstance) { throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); } const { email, password, productId } = parsed.data; - const user = await repo.getByEmail(email); + const user = await repo.getByEmail(email, productId); if (!user) throw new UnauthorizedError('Invalid email or password'); if (user.status !== 'active') throw new UnauthorizedError('Account is disabled'); @@ -54,7 +54,7 @@ export async function authRoutes(app: FastifyInstance) { } const { email, password, displayName, role, productId } = parsed.data; - const existing = await repo.getByEmail(email); + const existing = await repo.getByEmail(email, productId); if (existing) throw new BadRequestError('Email already registered'); const now = new Date().toISOString(); diff --git a/services/platform-service/src/modules/flags/repository.ts b/services/platform-service/src/modules/flags/repository.ts index 2a713a51..2bfb0347 100644 --- a/services/platform-service/src/modules/flags/repository.ts +++ b/services/platform-service/src/modules/flags/repository.ts @@ -3,29 +3,28 @@ */ import { getContainer } from '../../lib/cosmos.js'; -import { PRODUCT_ID } from '../../lib/product-config.js'; import type { FeatureFlagDoc } from './types.js'; function container() { return getContainer('feature_flags'); } -export async function list(): Promise { +export async function list(productId: string): Promise { const { resources } = await container() .items.query({ query: 'SELECT * FROM c WHERE c.productId = @productId ORDER BY c.key ASC', - parameters: [{ name: '@productId', value: PRODUCT_ID }], + parameters: [{ name: '@productId', value: productId }], }) .fetchAll(); return resources; } -export async function getByKey(key: string): Promise { +export async function getByKey(key: string, productId: string): Promise { const { resources } = await container() .items.query({ query: 'SELECT * FROM c WHERE c.productId = @productId AND c.key = @key', parameters: [ - { name: '@productId', value: PRODUCT_ID }, + { name: '@productId', value: productId }, { name: '@key', value: key }, ], }) diff --git a/services/platform-service/src/modules/flags/routes.ts b/services/platform-service/src/modules/flags/routes.ts index f0876747..9141b36c 100644 --- a/services/platform-service/src/modules/flags/routes.ts +++ b/services/platform-service/src/modules/flags/routes.ts @@ -11,7 +11,7 @@ */ import type { FastifyInstance } from 'fastify'; -import { PRODUCT_ID } from '../../lib/product-config.js'; +import { getRequestProductId } from '../../lib/request-context.js'; import { BadRequestError, NotFoundError } from '../../lib/errors.js'; import * as repo from './repository.js'; import { CreateFlagSchema, UpdateFlagSchema, type FeatureFlagDoc } from './types.js'; @@ -34,16 +34,18 @@ export { hashUserFlag }; export async function flagRoutes(app: FastifyInstance) { // List all flags - app.get('/flags', async () => { - return { flags: await repo.list() }; + app.get('/flags', async req => { + const productId = getRequestProductId(req); + return { flags: await repo.list(productId) }; }); // Polling endpoint for clients // ?userId=xxx — deterministic hash ensures same user always gets same flag assignment // ?platform=xxx — filter flags by platform app.get('/flags/poll', async req => { + const productId = getRequestProductId(req); const { platform, userId } = req.query as { platform?: string; userId?: string }; - const all = await repo.list(); + const all = await repo.list(productId); const active = all.filter(f => { if (!f.enabled) return false; if (f.platforms.length > 0 && platform && !f.platforms.includes(platform)) return false; @@ -63,31 +65,33 @@ export async function flagRoutes(app: FastifyInstance) { flags[f.key] = Math.random() * 100 < f.percentage; } } - return { flags, productId: PRODUCT_ID }; + return { flags, productId }; }); // Get flag app.get('/flags/:key', async req => { const { key } = req.params as { key: string }; - const flag = await repo.getByKey(key); + const productId = getRequestProductId(req); + const flag = await repo.getByKey(key, productId); if (!flag) throw new NotFoundError('Flag not found'); return flag; }); // Create flag app.post('/flags', async (req, reply) => { + const productId = getRequestProductId(req); const parsed = CreateFlagSchema.safeParse(req.body); if (!parsed.success) { throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); } const input = parsed.data; - const existing = await repo.getByKey(input.key); + const existing = await repo.getByKey(input.key, productId); if (existing) throw new BadRequestError(`Flag "${input.key}" already exists`); const now = new Date().toISOString(); const doc: FeatureFlagDoc = { - id: `flag_${PRODUCT_ID}_${input.key}`, - productId: PRODUCT_ID, + id: `flag_${productId}_${input.key}`, + productId, ...input, createdAt: now, updatedAt: now, @@ -100,7 +104,8 @@ export async function flagRoutes(app: FastifyInstance) { // Update flag app.put('/flags/:key', async req => { const { key } = req.params as { key: string }; - const flag = await repo.getByKey(key); + const productId = getRequestProductId(req); + const flag = await repo.getByKey(key, productId); if (!flag) throw new NotFoundError('Flag not found'); const parsed = UpdateFlagSchema.safeParse(req.body); @@ -115,7 +120,8 @@ export async function flagRoutes(app: FastifyInstance) { // Delete flag app.delete('/flags/:key', async req => { const { key } = req.params as { key: string }; - const flag = await repo.getByKey(key); + const productId = getRequestProductId(req); + const flag = await repo.getByKey(key, productId); if (!flag) throw new NotFoundError('Flag not found'); await repo.remove(flag.id); return { success: true }; @@ -124,7 +130,8 @@ export async function flagRoutes(app: FastifyInstance) { // Kill switch — disable all flags matching optional platform filter app.post('/flags/kill', async req => { const { platform, keys } = req.body as { platform?: string; keys?: string[] }; - const all = await repo.list(); + const productId = getRequestProductId(req); + const all = await repo.list(productId); const toDisable = all.filter(f => { if (keys && keys.length > 0 && !keys.includes(f.key)) return false; if (platform && f.platforms.length > 0 && !f.platforms.includes(platform)) return false; diff --git a/services/platform-service/src/modules/invitations/repository.ts b/services/platform-service/src/modules/invitations/repository.ts index 3af86c64..f2f62fba 100644 --- a/services/platform-service/src/modules/invitations/repository.ts +++ b/services/platform-service/src/modules/invitations/repository.ts @@ -4,7 +4,6 @@ */ import { getContainer } from '../../lib/cosmos.js'; -import { PRODUCT_ID } from '../../lib/product-config.js'; import type { InvitationCodeDoc } from './types.js'; const CONTAINER = 'invitation_codes'; @@ -13,13 +12,17 @@ function container() { return getContainer(CONTAINER); } -export async function list(limit = 100, offset = 0): Promise { +export async function list( + limit = 100, + offset = 0, + productId?: string +): Promise { const { resources } = await container() .items.query({ query: 'SELECT * FROM c WHERE c.productId = @productId ORDER BY c.createdAt DESC OFFSET @offset LIMIT @limit', parameters: [ - { name: '@productId', value: PRODUCT_ID }, + { name: '@productId', value: productId ?? '' }, { name: '@offset', value: offset }, { name: '@limit', value: limit }, ], @@ -37,12 +40,15 @@ export async function getById(id: string): Promise { } } -export async function getByCode(code: string): Promise { +export async function getByCode( + code: string, + productId?: string +): Promise { const { resources } = await container() .items.query({ query: 'SELECT * FROM c WHERE c.productId = @productId AND c.code = @code', parameters: [ - { name: '@productId', value: PRODUCT_ID }, + { name: '@productId', value: productId ?? '' }, { name: '@code', value: code.toUpperCase() }, ], }) @@ -94,11 +100,11 @@ export async function remove(id: string): Promise { } } -export async function count(): Promise { +export async function count(productId?: string): Promise { const { resources } = await container() .items.query({ query: 'SELECT VALUE COUNT(1) FROM c WHERE c.productId = @productId', - parameters: [{ name: '@productId', value: PRODUCT_ID }], + parameters: [{ name: '@productId', value: productId ?? '' }], }) .fetchAll(); return resources[0] ?? 0; diff --git a/services/platform-service/src/modules/invitations/routes.ts b/services/platform-service/src/modules/invitations/routes.ts index 05c29b7f..0b9012e9 100644 --- a/services/platform-service/src/modules/invitations/routes.ts +++ b/services/platform-service/src/modules/invitations/routes.ts @@ -12,7 +12,7 @@ */ import type { FastifyInstance } from 'fastify'; -import { PRODUCT_ID } from '../../lib/product-config.js'; +import { getRequestProductId } from '../../lib/request-context.js'; import { BadRequestError, NotFoundError } from '../../lib/errors.js'; import { dispatchInvitationRedeemed } from '../../lib/webhooks.js'; import * as repo from './repository.js'; @@ -27,13 +27,15 @@ export async function invitationRoutes(app: FastifyInstance) { // List app.get('/invitations', async req => { const { limit = '100', offset = '0' } = req.query as Record; - const items = await repo.list(Number(limit), Number(offset)); + const productId = getRequestProductId(req); + const items = await repo.list(Number(limit), Number(offset), productId); return { invitations: items, count: items.length }; }); // Count - app.get('/invitations/count', async () => { - const total = await repo.count(); + app.get('/invitations/count', async req => { + const productId = getRequestProductId(req); + const total = await repo.count(productId); return { count: total }; }); @@ -53,9 +55,10 @@ export async function invitationRoutes(app: FastifyInstance) { } const input = parsed.data; const now = new Date().toISOString(); + const productId = getRequestProductId(req); const doc: InvitationCodeDoc = { id: `inv_${crypto.randomUUID()}`, - productId: PRODUCT_ID, + productId, code: input.code.toUpperCase().replace(/[^A-Z0-9-]/g, ''), description: input.description, createdBy: input.createdBy, @@ -109,6 +112,7 @@ export async function invitationRoutes(app: FastifyInstance) { throw new BadRequestError('Maximum 500 invitations per bulk request'); } + const productId = getRequestProductId(req); const results: { created: InvitationCodeDoc[]; errors: { index: number; error: string }[] } = { created: [], errors: [], @@ -127,7 +131,7 @@ export async function invitationRoutes(app: FastifyInstance) { const now = new Date().toISOString(); const doc: InvitationCodeDoc = { id: `inv_${crypto.randomUUID()}`, - productId: PRODUCT_ID, + productId, code: input.code.toUpperCase().replace(/[^A-Z0-9-]/g, ''), description: input.description, createdBy: input.createdBy, diff --git a/services/platform-service/src/modules/items/routes.ts b/services/platform-service/src/modules/items/routes.ts index bbd28d0e..27f59f70 100644 --- a/services/platform-service/src/modules/items/routes.ts +++ b/services/platform-service/src/modules/items/routes.ts @@ -11,7 +11,7 @@ */ import type { FastifyInstance } from 'fastify'; -import { DEFAULT_PRODUCT_ID } from '../../lib/product-config.js'; +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'; @@ -28,8 +28,7 @@ export async function itemRoutes(app: FastifyInstance) { // Stats — must be registered before :id param route app.get('/items/stats', async req => { const auth = await extractAuth(req); - const { productId } = req.query as { productId?: string }; - const pid = productId || DEFAULT_PRODUCT_ID; + const pid = getRequestProductId(req); const { items } = await repo.list({ productId: pid, @@ -67,7 +66,7 @@ export async function itemRoutes(app: FastifyInstance) { throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); } const query = parsed.data; - if (!query.productId) query.productId = DEFAULT_PRODUCT_ID; + if (!query.productId) query.productId = getRequestProductId(req); const { items, total } = await repo.list(query); return { items, total, limit: query.limit, offset: query.offset }; }); @@ -89,7 +88,7 @@ export async function itemRoutes(app: FastifyInstance) { throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); } const input = parsed.data; - const pid = input.productId || DEFAULT_PRODUCT_ID; + const pid = input.productId || getRequestProductId(req); const now = new Date().toISOString(); const doc: TrackerItemDoc = { diff --git a/services/platform-service/src/modules/licenses/repository.ts b/services/platform-service/src/modules/licenses/repository.ts index 488fa676..13477954 100644 --- a/services/platform-service/src/modules/licenses/repository.ts +++ b/services/platform-service/src/modules/licenses/repository.ts @@ -4,24 +4,23 @@ import crypto from 'crypto'; import { getContainer } from '../../lib/cosmos.js'; -import { PRODUCT_ID, LICENSE_PREFIX } from '../../lib/product-config.js'; import type { LicenseDoc } from './types.js'; function container() { return getContainer('licenses'); } -export function generateKey(): string { +export function generateKey(licensePrefix: string): string { const seg = () => crypto.randomBytes(2).toString('hex').toUpperCase(); - return `${LICENSE_PREFIX}-${seg()}-${seg()}-${seg()}`; + return `${licensePrefix}-${seg()}-${seg()}-${seg()}`; } -export async function getByKey(key: string): Promise { +export async function getByKey(key: string, productId: string): Promise { const { resources } = await container() .items.query({ query: 'SELECT * FROM c WHERE c.productId = @productId AND c.key = @key', parameters: [ - { name: '@productId', value: PRODUCT_ID }, + { name: '@productId', value: productId }, { name: '@key', value: key.toUpperCase() }, ], }) @@ -29,13 +28,13 @@ export async function getByKey(key: string): Promise { return resources[0] ?? null; } -export async function getByUserId(userId: string): Promise { +export async function getByUserId(userId: string, productId: string): Promise { const { resources } = await container() .items.query({ query: 'SELECT * FROM c WHERE c.productId = @productId AND c.userId = @userId ORDER BY c.createdAt DESC', parameters: [ - { name: '@productId', value: PRODUCT_ID }, + { name: '@productId', value: productId }, { name: '@userId', value: userId }, ], }) diff --git a/services/platform-service/src/modules/licenses/routes.ts b/services/platform-service/src/modules/licenses/routes.ts index 9e2bdb9e..8342c04e 100644 --- a/services/platform-service/src/modules/licenses/routes.ts +++ b/services/platform-service/src/modules/licenses/routes.ts @@ -9,7 +9,7 @@ */ import type { FastifyInstance } from 'fastify'; -import { PRODUCT_ID } from '../../lib/product-config.js'; +import { getRequestProductId, getRequestProductConfig } from '../../lib/request-context.js'; import { BadRequestError, NotFoundError } from '../../lib/errors.js'; import * as repo from './repository.js'; import { @@ -28,11 +28,13 @@ export async function licenseRoutes(app: FastifyInstance) { } const input = parsed.data; const now = new Date().toISOString(); - const key = repo.generateKey(); + const productId = getRequestProductId(req); + const productConfig = getRequestProductConfig(req); + const key = repo.generateKey(productConfig.licensePrefix); const doc: LicenseDoc = { id: `lic_${crypto.randomUUID()}`, - productId: PRODUCT_ID, + productId, key, userId: input.userId, plan: input.plan, @@ -56,7 +58,8 @@ export async function licenseRoutes(app: FastifyInstance) { throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); } const { key, deviceId } = parsed.data; - const license = await repo.getByKey(key); + const productId = getRequestProductId(req); + const license = await repo.getByKey(key, productId); if (!license) throw new NotFoundError('License not found'); if (license.status !== 'active') throw new BadRequestError('License is not active'); if (license.expiresAt && new Date(license.expiresAt) < new Date()) { @@ -86,7 +89,8 @@ export async function licenseRoutes(app: FastifyInstance) { throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); } const { key, deviceId } = parsed.data; - const license = await repo.getByKey(key); + const productId = getRequestProductId(req); + const license = await repo.getByKey(key, productId); if (!license) throw new NotFoundError('License not found'); const updated = await repo.update(license.id, license.userId, { @@ -99,7 +103,8 @@ export async function licenseRoutes(app: FastifyInstance) { // Status app.get('/licenses/status/:key', async req => { const { key } = req.params as { key: string }; - const license = await repo.getByKey(key); + const productId = getRequestProductId(req); + const license = await repo.getByKey(key, productId); if (!license) throw new NotFoundError('License not found'); return { key: license.key, @@ -114,7 +119,8 @@ export async function licenseRoutes(app: FastifyInstance) { // User licenses app.get('/licenses/user/:userId', async req => { const { userId } = req.params as { userId: string }; - const licenses = await repo.getByUserId(userId); + const productId = getRequestProductId(req); + const licenses = await repo.getByUserId(userId, productId); return { licenses }; }); } diff --git a/services/platform-service/src/modules/memory/routes.ts b/services/platform-service/src/modules/memory/routes.ts index 201193ee..d345d202 100644 --- a/services/platform-service/src/modules/memory/routes.ts +++ b/services/platform-service/src/modules/memory/routes.ts @@ -12,7 +12,7 @@ import type { FastifyInstance } from 'fastify'; import { randomUUID } from 'node:crypto'; -import { DEFAULT_PRODUCT_ID } from '../../lib/product-config.js'; +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'; @@ -48,7 +48,7 @@ export async function memoryRoutes(app: FastifyInstance) { } const q = parsed.data; - const pid = q.productId || DEFAULT_PRODUCT_ID; + const pid = q.productId || getRequestProductId(req); const { items } = await repo.list({ productId: pid, @@ -71,10 +71,11 @@ export async function memoryRoutes(app: FastifyInstance) { } const input = parsed.data; - const pid = input.productId || DEFAULT_PRODUCT_ID; + const pid = input.productId || getRequestProductId(req); const now = new Date().toISOString(); const triage = input.triageResult ?? defaultTriage(input.rawContent); - const brainIds = input.brainIds && input.brainIds.length > 0 ? input.brainIds : [triage.suggestedBrainId]; + const brainIds = + input.brainIds && input.brainIds.length > 0 ? input.brainIds : [triage.suggestedBrainId]; const doc: MemoryItemDoc = { id: `mem_${Date.now()}_${randomUUID()}`, @@ -110,7 +111,7 @@ export async function memoryRoutes(app: FastifyInstance) { throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); } - const pid = (req.query as { productId?: string }).productId || DEFAULT_PRODUCT_ID; + const pid = (req.query as { productId?: string }).productId || getRequestProductId(req); const item = await repo.getById(id, auth.sub, pid); if (!item) throw new NotFoundError('Memory item not found'); @@ -132,7 +133,7 @@ export async function memoryRoutes(app: FastifyInstance) { throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); } - const pid = (req.query as { productId?: string }).productId || DEFAULT_PRODUCT_ID; + const pid = (req.query as { productId?: string }).productId || getRequestProductId(req); const item = await repo.getById(id, auth.sub, pid); if (!item) throw new NotFoundError('Memory item not found'); @@ -160,7 +161,7 @@ export async function memoryRoutes(app: FastifyInstance) { app.delete('/memory-items/:id', async req => { const auth = await extractAuth(req); const { id } = req.params as { id: string }; - const pid = (req.query as { productId?: string }).productId || DEFAULT_PRODUCT_ID; + const pid = (req.query as { productId?: string }).productId || getRequestProductId(req); const item = await repo.getById(id, auth.sub, pid); if (!item) throw new NotFoundError('Memory item not found'); diff --git a/services/platform-service/src/modules/notifications/repository.ts b/services/platform-service/src/modules/notifications/repository.ts index 6c329e4c..5c83b660 100644 --- a/services/platform-service/src/modules/notifications/repository.ts +++ b/services/platform-service/src/modules/notifications/repository.ts @@ -3,7 +3,6 @@ */ import { getContainer } from '../../lib/cosmos.js'; -import { PRODUCT_ID } from '../../lib/product-config.js'; import type { DeviceDoc, NotificationPrefsDoc } from './types.js'; function deviceContainer() { @@ -16,12 +15,12 @@ function prefsContainer() { // ── Devices ── -export async function getDevicesByUser(userId: string): Promise { +export async function getDevicesByUser(userId: string, productId: string): Promise { const { resources } = await deviceContainer() .items.query({ query: 'SELECT * FROM c WHERE c.productId = @productId AND c.userId = @userId', parameters: [ - { name: '@productId', value: PRODUCT_ID }, + { name: '@productId', value: productId }, { name: '@userId', value: userId }, ], }) @@ -45,8 +44,11 @@ export async function removeDevice(id: string, userId: string): Promise // ── Preferences ── -export async function getPrefs(userId: string): Promise { - const id = `prefs_${PRODUCT_ID}_${userId}`; +export async function getPrefs( + userId: string, + productId: string +): Promise { + const id = `prefs_${productId}_${userId}`; try { const { resource } = await prefsContainer().item(id, userId).read(); return resource ?? null; diff --git a/services/platform-service/src/modules/notifications/routes.ts b/services/platform-service/src/modules/notifications/routes.ts index 845fab14..beec338c 100644 --- a/services/platform-service/src/modules/notifications/routes.ts +++ b/services/platform-service/src/modules/notifications/routes.ts @@ -9,7 +9,7 @@ */ import type { FastifyInstance } from 'fastify'; -import { PRODUCT_ID } from '../../lib/product-config.js'; +import { getRequestProductId } from '../../lib/request-context.js'; import { BadRequestError, NotFoundError } from '../../lib/errors.js'; import * as repo from './repository.js'; import { @@ -28,9 +28,10 @@ export async function notificationRoutes(app: FastifyInstance) { } const input = parsed.data; const now = new Date().toISOString(); + const productId = getRequestProductId(req); const doc: DeviceDoc = { id: `dev_${input.userId}_${input.deviceId}`, - productId: PRODUCT_ID, + productId, ...input, lastSeenAt: now, createdAt: now, @@ -42,7 +43,8 @@ export async function notificationRoutes(app: FastifyInstance) { // List user devices app.get('/devices/:userId', async req => { const { userId } = req.params as { userId: string }; - return { devices: await repo.getDevicesByUser(userId) }; + const productId = getRequestProductId(req); + return { devices: await repo.getDevicesByUser(userId, productId) }; }); // Remove device @@ -58,7 +60,8 @@ export async function notificationRoutes(app: FastifyInstance) { // Get notification prefs app.get('/notifications/prefs/:userId', async req => { const { userId } = req.params as { userId: string }; - const prefs = await repo.getPrefs(userId); + const productId = getRequestProductId(req); + const prefs = await repo.getPrefs(userId, productId); if (!prefs) { // Return defaults return { @@ -78,11 +81,12 @@ export async function notificationRoutes(app: FastifyInstance) { if (!parsed.success) { throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); } - const existing = await repo.getPrefs(userId); + const productId = getRequestProductId(req); + const existing = await repo.getPrefs(userId, productId); const now = new Date().toISOString(); const doc: NotificationPrefsDoc = { - id: `prefs_${PRODUCT_ID}_${userId}`, - productId: PRODUCT_ID, + id: `prefs_${productId}_${userId}`, + productId, userId, pushEnabled: parsed.data.pushEnabled ?? existing?.pushEnabled ?? true, emailEnabled: parsed.data.emailEnabled ?? existing?.emailEnabled ?? true, diff --git a/services/platform-service/src/modules/plans/repository.ts b/services/platform-service/src/modules/plans/repository.ts index 1beeabad..e0b2c46a 100644 --- a/services/platform-service/src/modules/plans/repository.ts +++ b/services/platform-service/src/modules/plans/repository.ts @@ -3,7 +3,6 @@ */ import { getContainer } from '../../lib/cosmos.js'; -import { PRODUCT_ID } from '../../lib/product-config.js'; import type { PlanConfig } from './types.js'; import { DEFAULT_PLANS } from './types.js'; @@ -11,27 +10,27 @@ function container() { return getContainer('plans'); } -export async function list(): Promise { +export async function list(productId: string): Promise { const { resources } = await container() .items.query({ query: 'SELECT * FROM c WHERE c.productId = @productId ORDER BY c.price ASC', - parameters: [{ name: '@productId', value: PRODUCT_ID }], + parameters: [{ name: '@productId', value: productId }], }) .fetchAll(); // If no plans in DB yet, return defaults if (resources.length === 0) { - return getDefaults(); + return getDefaults(productId); } return resources; } -export async function getByName(name: string): Promise { +export async function getByName(name: string, productId: string): Promise { const { resources } = await container() .items.query({ query: 'SELECT * FROM c WHERE c.productId = @productId AND c.name = @name', parameters: [ - { name: '@productId', value: PRODUCT_ID }, + { name: '@productId', value: productId }, { name: '@name', value: name }, ], }) @@ -56,12 +55,12 @@ export async function update(id: string, updates: Partial): Promise< } } -export function getDefaults(): PlanConfig[] { +export function getDefaults(productId: string): PlanConfig[] { const now = new Date().toISOString(); return DEFAULT_PLANS.map(p => ({ ...p, - id: `plan_${PRODUCT_ID}_${p.name}`, - productId: PRODUCT_ID, + id: `plan_${productId}_${p.name}`, + productId, createdAt: now, updatedAt: now, })); diff --git a/services/platform-service/src/modules/plans/routes.ts b/services/platform-service/src/modules/plans/routes.ts index a3903d35..a2c8cfae 100644 --- a/services/platform-service/src/modules/plans/routes.ts +++ b/services/platform-service/src/modules/plans/routes.ts @@ -9,24 +9,26 @@ */ import type { FastifyInstance } from 'fastify'; -import { PRODUCT_ID } from '../../lib/product-config.js'; +import { getRequestProductId } from '../../lib/request-context.js'; import { BadRequestError, NotFoundError } from '../../lib/errors.js'; import * as repo from './repository.js'; import { CreatePlanSchema, UpdatePlanSchema, type PlanConfig } from './types.js'; export async function planRoutes(app: FastifyInstance) { // List plans - app.get('/plans', async () => { - return { plans: await repo.list() }; + app.get('/plans', async req => { + const productId = getRequestProductId(req); + return { plans: await repo.list(productId) }; }); // Get by name app.get('/plans/:name', async req => { const { name } = req.params as { name: string }; - const plan = await repo.getByName(name); + const productId = getRequestProductId(req); + const plan = await repo.getByName(name, productId); if (!plan) { // Fall back to defaults - const defaults = repo.getDefaults(); + const defaults = repo.getDefaults(productId); const def = defaults.find(d => d.name === name); if (!def) throw new NotFoundError('Plan not found'); return def; @@ -42,9 +44,10 @@ export async function planRoutes(app: FastifyInstance) { } const input = parsed.data; const now = new Date().toISOString(); + const productId = getRequestProductId(req); const doc: PlanConfig = { - id: `plan_${PRODUCT_ID}_${input.name}`, - productId: PRODUCT_ID, + id: `plan_${productId}_${input.name}`, + productId, ...input, createdAt: now, updatedAt: now, @@ -68,10 +71,11 @@ export async function planRoutes(app: FastifyInstance) { // Seed defaults app.post('/plans/seed', async (req, reply) => { - const defaults = repo.getDefaults(); + const productId = getRequestProductId(req); + const defaults = repo.getDefaults(productId); const results: PlanConfig[] = []; for (const plan of defaults) { - const existing = await repo.getByName(plan.name); + const existing = await repo.getByName(plan.name, productId); if (!existing) { results.push(await repo.create(plan)); } else { diff --git a/services/platform-service/src/modules/promos/routes.ts b/services/platform-service/src/modules/promos/routes.ts index de89e78f..c95c8b1c 100644 --- a/services/platform-service/src/modules/promos/routes.ts +++ b/services/platform-service/src/modules/promos/routes.ts @@ -9,7 +9,7 @@ import type { FastifyInstance } from 'fastify'; import Stripe from 'stripe'; -import { PRODUCT_ID } from '../../lib/product-config.js'; +import { getRequestProductId } from '../../lib/request-context.js'; import { BadRequestError } from '../../lib/errors.js'; import { CreatePromoSchema, type PromoCodeResponse } from './types.js'; @@ -67,6 +67,7 @@ export async function promoRoutes(app: FastifyInstance) { throw new BadRequestError('Either percentOff or amountOff is required'); } + const productId = getRequestProductId(req); const stripe = getStripe(); // Create coupon first @@ -76,7 +77,7 @@ export async function promoRoutes(app: FastifyInstance) { ...(input.amountOff && { amount_off: input.amountOff, currency: input.currency }), ...(input.duration === 'repeating' && input.durationInMonths && { duration_in_months: input.durationInMonths }), - metadata: { createdBy: input.createdBy, productId: PRODUCT_ID }, + metadata: { createdBy: input.createdBy, productId }, }; const coupon = await stripe.coupons.create(couponParams); @@ -88,7 +89,7 @@ export async function promoRoutes(app: FastifyInstance) { ...(input.expiresAt && { expires_at: Math.floor(new Date(input.expiresAt).getTime() / 1000), }), - metadata: { createdBy: input.createdBy, productId: PRODUCT_ID }, + metadata: { createdBy: input.createdBy, productId }, }; const promo = await stripe.promotionCodes.create(promoParams); diff --git a/services/platform-service/src/modules/public/routes.ts b/services/platform-service/src/modules/public/routes.ts index 470faef3..1e189869 100644 --- a/services/platform-service/src/modules/public/routes.ts +++ b/services/platform-service/src/modules/public/routes.ts @@ -10,7 +10,7 @@ import type { FastifyInstance } from 'fastify'; import rateLimit from '@fastify/rate-limit'; -import { DEFAULT_PRODUCT_ID } from '../../lib/product-config.js'; +import { getRequestProductId } from '../../lib/request-context.js'; import { BadRequestError, NotFoundError } from '../../lib/errors.js'; import * as itemRepo from '../items/repository.js'; import * as voteRepo from '../votes/repository.js'; @@ -35,7 +35,7 @@ export async function publicRoutes(app: FastifyInstance) { const query = parsed.data; return itemRepo.list({ - productId: query.productId || DEFAULT_PRODUCT_ID, + productId: query.productId || getRequestProductId(req), visibility: 'public', type: query.type, status: query.status, @@ -50,7 +50,7 @@ export async function publicRoutes(app: FastifyInstance) { // Public roadmap stats app.get('/public/roadmap/stats', async req => { const { productId } = req.query as { productId?: string }; - const pid = productId || DEFAULT_PRODUCT_ID; + const pid = productId || getRequestProductId(req); const { items } = await itemRepo.list({ productId: pid, @@ -94,7 +94,7 @@ export async function publicRoutes(app: FastifyInstance) { throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); } const input = parsed.data; - const pid = input.productId || DEFAULT_PRODUCT_ID; + const pid = input.productId || getRequestProductId(req); const now = new Date().toISOString(); const doc: TrackerItemDoc = { diff --git a/services/platform-service/src/modules/ratelimit/routes.ts b/services/platform-service/src/modules/ratelimit/routes.ts index 2af54bc5..1d80cfff 100644 --- a/services/platform-service/src/modules/ratelimit/routes.ts +++ b/services/platform-service/src/modules/ratelimit/routes.ts @@ -8,7 +8,8 @@ */ import type { FastifyInstance } from 'fastify'; -import { PRODUCT_ID } from '../../lib/product-config.js'; +import { DEFAULT_PRODUCT_ID } from '../../lib/product-config.js'; +import { getRequestProductId } from '../../lib/request-context.js'; import { BadRequestError } from '../../lib/errors.js'; import { CheckRateLimitSchema, RateLimitConfigSchema } from './types.js'; import type { RateLimitRule, RateLimitConfig } from './types.js'; @@ -33,8 +34,8 @@ function loadConfig(): Map { } // Sensible defaults - configs.set(PRODUCT_ID, { - productId: PRODUCT_ID, + configs.set(DEFAULT_PRODUCT_ID, { + productId: DEFAULT_PRODUCT_ID, rules: [ { maxRequests: 60, windowSeconds: 60 }, // 60 req/min global { maxRequests: 5, windowSeconds: 60, routePrefix: '/api/auth' }, // 5 auth attempts/min @@ -85,17 +86,19 @@ export async function rateLimitRoutes(app: FastifyInstance) { // Reset app.post('/ratelimit/reset', async req => { - const { key, productId = PRODUCT_ID } = req.body as { key?: string; productId?: string }; + const { key, productId } = req.body as { key?: string; productId?: string }; + const pid = productId || getRequestProductId(req); if (!key) throw new BadRequestError('key is required'); - store.reset(`${productId}:${key}`); + store.reset(`${pid}:${key}`); return { reset: true }; }); // Get config app.get('/ratelimit/config', async req => { - const { productId = PRODUCT_ID } = req.query as { productId?: string }; - const config = configMap.get(productId); - return config ?? { productId, rules: [] }; + const { productId } = req.query as { productId?: string }; + const pid = productId || getRequestProductId(req); + const config = configMap.get(pid); + return config ?? { productId: pid, rules: [] }; }); // Update config (admin) diff --git a/services/platform-service/src/modules/referrals/repository.ts b/services/platform-service/src/modules/referrals/repository.ts index 61719d84..13dbcf98 100644 --- a/services/platform-service/src/modules/referrals/repository.ts +++ b/services/platform-service/src/modules/referrals/repository.ts @@ -4,7 +4,6 @@ */ import { getContainer } from '../../lib/cosmos.js'; -import { PRODUCT_ID } from '../../lib/product-config.js'; import type { ReferralDoc } from './types.js'; const CONTAINER = 'referrals'; @@ -13,13 +12,13 @@ function container() { return getContainer(CONTAINER); } -export async function listAll(limit = 100, offset = 0): Promise { +export async function listAll(limit = 100, offset = 0, productId?: string): Promise { const { resources } = await container() .items.query({ query: 'SELECT * FROM c WHERE c.productId = @productId ORDER BY c.createdAt DESC OFFSET @offset LIMIT @limit', parameters: [ - { name: '@productId', value: PRODUCT_ID }, + { name: '@productId', value: productId ?? '' }, { name: '@offset', value: offset }, { name: '@limit', value: limit }, ], @@ -28,13 +27,13 @@ export async function listAll(limit = 100, offset = 0): Promise { return resources; } -export async function getByReferrer(referrerId: string): Promise { +export async function getByReferrer(referrerId: string, productId: string): Promise { const { resources } = await container() .items.query({ query: 'SELECT * FROM c WHERE c.productId = @productId AND c.referrerId = @rid ORDER BY c.createdAt DESC', parameters: [ - { name: '@productId', value: PRODUCT_ID }, + { name: '@productId', value: productId }, { name: '@rid', value: referrerId }, ], }) @@ -42,13 +41,16 @@ export async function getByReferrer(referrerId: string): Promise return resources; } -export async function getByReferredEmail(email: string): Promise { +export async function getByReferredEmail( + email: string, + productId: string +): Promise { const { resources } = await container() .items.query({ query: 'SELECT * FROM c WHERE c.productId = @productId AND c.referredEmail = @email ORDER BY c.createdAt DESC', parameters: [ - { name: '@productId', value: PRODUCT_ID }, + { name: '@productId', value: productId }, { name: '@email', value: email.toLowerCase() }, ], }) @@ -86,7 +88,7 @@ export async function update( } } -export async function countReferrals(): Promise<{ +export async function countReferrals(productId: string): Promise<{ total: number; completed: number; rewarded: number; @@ -94,21 +96,21 @@ export async function countReferrals(): Promise<{ const { resources: totalRes } = await container() .items.query({ query: 'SELECT VALUE COUNT(1) FROM c WHERE c.productId = @productId', - parameters: [{ name: '@productId', value: PRODUCT_ID }], + parameters: [{ name: '@productId', value: productId }], }) .fetchAll(); const { resources: completedRes } = await container() .items.query({ query: "SELECT VALUE COUNT(1) FROM c WHERE c.productId = @productId AND c.status IN ('signed_up', 'subscribed', 'rewarded')", - parameters: [{ name: '@productId', value: PRODUCT_ID }], + parameters: [{ name: '@productId', value: productId }], }) .fetchAll(); const { resources: rewardedRes } = await container() .items.query({ query: "SELECT VALUE COUNT(1) FROM c WHERE c.productId = @productId AND c.status = 'rewarded'", - parameters: [{ name: '@productId', value: PRODUCT_ID }], + parameters: [{ name: '@productId', value: productId }], }) .fetchAll(); return { diff --git a/services/platform-service/src/modules/referrals/routes.ts b/services/platform-service/src/modules/referrals/routes.ts index b471e71e..d2ab11e3 100644 --- a/services/platform-service/src/modules/referrals/routes.ts +++ b/services/platform-service/src/modules/referrals/routes.ts @@ -10,7 +10,7 @@ */ import type { FastifyInstance } from 'fastify'; -import { PRODUCT_ID } from '../../lib/product-config.js'; +import { getRequestProductId } from '../../lib/request-context.js'; import { BadRequestError, NotFoundError } from '../../lib/errors.js'; import { dispatchReferralStatusChanged } from '../../lib/webhooks.js'; import * as repo from './repository.js'; @@ -20,26 +20,30 @@ export async function referralRoutes(app: FastifyInstance) { // List all app.get('/referrals', async req => { const { limit = '100', offset = '0' } = req.query as Record; - const items = await repo.listAll(Number(limit), Number(offset)); + const productId = getRequestProductId(req); + const items = await repo.listAll(Number(limit), Number(offset), productId); return { referrals: items, count: items.length }; }); // Stats - app.get('/referrals/stats', async () => { - return repo.countReferrals(); + app.get('/referrals/stats', async req => { + const productId = getRequestProductId(req); + return repo.countReferrals(productId); }); // By referrer app.get('/referrals/by-referrer/:referrerId', async req => { const { referrerId } = req.params as { referrerId: string }; - const items = await repo.getByReferrer(referrerId); + const productId = getRequestProductId(req); + const items = await repo.getByReferrer(referrerId, productId); return { referrals: items, count: items.length }; }); // By referred email app.get('/referrals/by-email/:email', async req => { const { email } = req.params as { email: string }; - const doc = await repo.getByReferredEmail(email); + const productId = getRequestProductId(req); + const doc = await repo.getByReferredEmail(email, productId); if (!doc) throw new NotFoundError('Referral not found'); return doc; }); @@ -53,7 +57,8 @@ export async function referralRoutes(app: FastifyInstance) { const input = parsed.data; // Check if referral already exists for this email - const existing = await repo.getByReferredEmail(input.referredEmail); + const productId = getRequestProductId(req); + const existing = await repo.getByReferredEmail(input.referredEmail, productId); if (existing) { throw new BadRequestError('A referral already exists for this email'); } @@ -61,7 +66,7 @@ export async function referralRoutes(app: FastifyInstance) { const now = new Date().toISOString(); const doc: ReferralDoc = { id: `ref_${crypto.randomUUID()}`, - productId: PRODUCT_ID, + productId, referrerId: input.referrerId, referrerEmail: input.referrerEmail, referredUserId: null, diff --git a/services/platform-service/src/modules/stripe/routes.ts b/services/platform-service/src/modules/stripe/routes.ts index d0e4c6fa..2bae5675 100644 --- a/services/platform-service/src/modules/stripe/routes.ts +++ b/services/platform-service/src/modules/stripe/routes.ts @@ -12,7 +12,8 @@ import { randomUUID } from 'node:crypto'; import type { FastifyInstance, FastifyRequest } from 'fastify'; import Stripe from 'stripe'; -import { PRODUCT_ID } from '../../lib/product-config.js'; +import { DEFAULT_PRODUCT_ID } from '../../lib/product-config.js'; +import { getRequestProductId } from '../../lib/request-context.js'; import { getStripeForProduct, getPriceIds } from '../../lib/stripe.js'; import { BadRequestError } from '../../lib/errors.js'; import * as subRepo from '../subscriptions/repository.js'; @@ -54,7 +55,8 @@ export async function stripeRoutes(app: FastifyInstance) { throw new BadRequestError('userId, plan, successUrl, cancelUrl are required'); } - const stripe = getStripeForProduct(PRODUCT_ID); + const productId = getRequestProductId(req); + const stripe = getStripeForProduct(productId); const priceIds = getPriceIds(); const priceId = priceIds[plan]; if (!priceId) throw new BadRequestError(`No price configured for plan: ${plan}`); @@ -64,7 +66,7 @@ export async function stripeRoutes(app: FastifyInstance) { line_items: [{ price: priceId, quantity: 1 }], success_url: successUrl, cancel_url: cancelUrl, - metadata: { userId, productId: PRODUCT_ID, plan }, + metadata: { userId, productId, plan }, ...(trialDays && trialDays > 0 && { subscription_data: { trial_period_days: trialDays }, @@ -91,7 +93,7 @@ export async function stripeRoutes(app: FastifyInstance) { throw new BadRequestError('Missing stripe-signature or webhook secret'); } - const stripe = getStripeForProduct(PRODUCT_ID); + const stripe = getStripeForProduct(DEFAULT_PRODUCT_ID); let event: Stripe.Event; try { @@ -106,8 +108,8 @@ export async function stripeRoutes(app: FastifyInstance) { // Route by productId in metadata (multi-tenant) const metadata = getEventMetadata(event); - const eventProductId = metadata?.productId || PRODUCT_ID; - if (eventProductId !== PRODUCT_ID) { + const eventProductId = metadata?.productId || DEFAULT_PRODUCT_ID; + if (eventProductId !== DEFAULT_PRODUCT_ID) { app.log.info(`Ignoring event for product ${eventProductId}`); return { received: true, skipped: true }; } @@ -118,7 +120,7 @@ export async function stripeRoutes(app: FastifyInstance) { const userId = session.metadata?.userId; const plan = session.metadata?.plan || 'pro'; if (userId && session.customer) { - const existing = await subRepo.getByUserId(userId); + const existing = await subRepo.getByUserId(userId, eventProductId); const now = new Date(); const periodEnd = new Date(now); periodEnd.setMonth(periodEnd.getMonth() + 1); @@ -135,7 +137,7 @@ export async function stripeRoutes(app: FastifyInstance) { } else { await subRepo.createSubscription({ id: `sub_${userId}_${Date.now()}`, - productId: PRODUCT_ID, + productId: eventProductId, userId, plan: plan as SubscriptionDoc['plan'], status: 'active', @@ -159,7 +161,7 @@ export async function stripeRoutes(app: FastifyInstance) { if (session.amount_total && session.amount_total > 0) { await subRepo.createPayment({ id: `pay_${randomUUID()}`, - productId: PRODUCT_ID, + productId: eventProductId, userId, amount: session.amount_total, currency: session.currency || 'usd', @@ -176,7 +178,7 @@ export async function stripeRoutes(app: FastifyInstance) { case 'customer.subscription.updated': { const sub = event.data.object as Stripe.Subscription; const customerId = typeof sub.customer === 'string' ? sub.customer : sub.customer.id; - const existing = await subRepo.getByStripeCustomerId(customerId); + const existing = await subRepo.getByStripeCustomerId(customerId, eventProductId); if (existing) { const newStatus = sub.cancel_at_period_end ? 'cancelled' @@ -197,7 +199,7 @@ export async function stripeRoutes(app: FastifyInstance) { case 'customer.subscription.deleted': { const sub = event.data.object as Stripe.Subscription; const customerId = typeof sub.customer === 'string' ? sub.customer : sub.customer.id; - const existing = await subRepo.getByStripeCustomerId(customerId); + const existing = await subRepo.getByStripeCustomerId(customerId, eventProductId); if (existing) { await subRepo.updateSubscription(existing.id, existing.userId, { status: 'cancelled', @@ -215,11 +217,11 @@ export async function stripeRoutes(app: FastifyInstance) { const customerId = typeof invoice.customer === 'string' ? invoice.customer : invoice.customer?.id; if (customerId) { - const existing = await subRepo.getByStripeCustomerId(customerId); + const existing = await subRepo.getByStripeCustomerId(customerId, eventProductId); if (existing && invoice.amount_paid > 0) { await subRepo.createPayment({ id: `pay_${randomUUID()}`, - productId: PRODUCT_ID, + productId: eventProductId, userId: existing.userId, amount: invoice.amount_paid, currency: invoice.currency, @@ -248,12 +250,13 @@ export async function stripeRoutes(app: FastifyInstance) { throw new BadRequestError('userId and returnUrl are required'); } - const sub = await subRepo.getByUserId(userId); + const productId = getRequestProductId(req); + const sub = await subRepo.getByUserId(userId, productId); if (!sub?.stripeCustomerId) { throw new BadRequestError('No Stripe customer found for this user'); } - const stripe = getStripeForProduct(PRODUCT_ID); + const stripe = getStripeForProduct(productId); const session = await stripe.billingPortal.sessions.create({ customer: sub.stripeCustomerId, return_url: returnUrl, diff --git a/services/platform-service/src/modules/subscriptions/repository.ts b/services/platform-service/src/modules/subscriptions/repository.ts index 8cc7c0d0..42e76d65 100644 --- a/services/platform-service/src/modules/subscriptions/repository.ts +++ b/services/platform-service/src/modules/subscriptions/repository.ts @@ -3,7 +3,6 @@ */ import { getContainer } from '../../lib/cosmos.js'; -import { PRODUCT_ID } from '../../lib/product-config.js'; import type { SubscriptionDoc, PaymentDoc } from './types.js'; function subContainer() { @@ -16,13 +15,16 @@ function payContainer() { // ── Subscriptions ── -export async function getByUserId(userId: string): Promise { +export async function getByUserId( + userId: string, + productId: string +): Promise { const { resources } = await subContainer() .items.query({ query: 'SELECT * FROM c WHERE c.productId = @productId AND c.userId = @userId ORDER BY c.createdAt DESC', parameters: [ - { name: '@productId', value: PRODUCT_ID }, + { name: '@productId', value: productId }, { name: '@userId', value: userId }, ], }) @@ -31,14 +33,15 @@ export async function getByUserId(userId: string): Promise { const { resources } = await subContainer() .items.query({ query: 'SELECT * FROM c WHERE c.productId = @productId AND c.stripeCustomerId = @cid ORDER BY c.createdAt DESC', parameters: [ - { name: '@productId', value: PRODUCT_ID }, + { name: '@productId', value: productId }, { name: '@cid', value: stripeCustomerId }, ], }) @@ -69,13 +72,17 @@ export async function updateSubscription( // ── Payments ── -export async function getPaymentsByUser(userId: string, limit = 50): Promise { +export async function getPaymentsByUser( + userId: string, + productId: string, + limit = 50 +): Promise { const { resources } = await payContainer() .items.query({ query: 'SELECT * FROM c WHERE c.productId = @productId AND c.userId = @userId ORDER BY c.createdAt DESC OFFSET 0 LIMIT @limit', parameters: [ - { name: '@productId', value: PRODUCT_ID }, + { name: '@productId', value: productId }, { name: '@userId', value: userId }, { name: '@limit', value: limit }, ], diff --git a/services/platform-service/src/modules/subscriptions/routes.ts b/services/platform-service/src/modules/subscriptions/routes.ts index c34c03de..3694f85f 100644 --- a/services/platform-service/src/modules/subscriptions/routes.ts +++ b/services/platform-service/src/modules/subscriptions/routes.ts @@ -9,7 +9,7 @@ */ import type { FastifyInstance } from 'fastify'; -import { PRODUCT_ID } from '../../lib/product-config.js'; +import { getRequestProductId } from '../../lib/request-context.js'; import { BadRequestError, NotFoundError } from '../../lib/errors.js'; import * as repo from './repository.js'; import { @@ -24,7 +24,8 @@ export async function subscriptionRoutes(app: FastifyInstance) { // Get subscription by userId app.get('/subscriptions/:userId', async req => { const { userId } = req.params as { userId: string }; - const sub = await repo.getByUserId(userId); + const productId = getRequestProductId(req); + const sub = await repo.getByUserId(userId, productId); if (!sub) throw new NotFoundError('Subscription not found'); return sub; }); @@ -44,9 +45,10 @@ export async function subscriptionRoutes(app: FastifyInstance) { periodEnd.setMonth(periodEnd.getMonth() + 1); } + const productId = getRequestProductId(req); const doc: SubscriptionDoc = { id: `sub_${input.userId}_${Date.now()}`, - productId: PRODUCT_ID, + productId, userId: input.userId, plan: input.plan, status: input.status, @@ -74,7 +76,8 @@ export async function subscriptionRoutes(app: FastifyInstance) { if (!parsed.success) { throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); } - const existing = await repo.getByUserId(userId); + const productId = getRequestProductId(req); + const existing = await repo.getByUserId(userId, productId); if (!existing) throw new NotFoundError('Subscription not found'); const updated = await repo.updateSubscription(existing.id, userId, parsed.data); if (!updated) throw new NotFoundError('Subscription update failed'); @@ -85,7 +88,8 @@ export async function subscriptionRoutes(app: FastifyInstance) { app.get('/payments/:userId', async req => { const { userId } = req.params as { userId: string }; const { limit = '50' } = req.query as { limit?: string }; - return { payments: await repo.getPaymentsByUser(userId, Number(limit)) }; + const productId = getRequestProductId(req); + return { payments: await repo.getPaymentsByUser(userId, productId, Number(limit)) }; }); // Create payment @@ -95,9 +99,10 @@ export async function subscriptionRoutes(app: FastifyInstance) { throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); } const input = parsed.data; + const productId = getRequestProductId(req); const doc: PaymentDoc = { id: `pay_${crypto.randomUUID()}`, - productId: PRODUCT_ID, + productId, ...input, createdAt: new Date().toISOString(), }; diff --git a/services/platform-service/src/modules/usage/repository.ts b/services/platform-service/src/modules/usage/repository.ts index 9fc46477..aa505a61 100644 --- a/services/platform-service/src/modules/usage/repository.ts +++ b/services/platform-service/src/modules/usage/repository.ts @@ -3,7 +3,6 @@ */ import { getContainer } from '../../lib/cosmos.js'; -import { PRODUCT_ID } from '../../lib/product-config.js'; import type { UsageDoc, MonthlyUsage } from './types.js'; function container() { @@ -21,14 +20,14 @@ export async function getByDate(userId: string, date: string): Promise { - const { userId, days = 30, limit = 100 } = options; + const { userId, days = 30, limit = 100, productId = '' } = options; const since = new Date(Date.now() - days * 86400000).toISOString().slice(0, 10); let queryText = 'SELECT * FROM c WHERE c.productId = @productId AND c.date >= @since'; const parameters: { name: string; value: string | number }[] = [ - { name: '@productId', value: PRODUCT_ID }, + { name: '@productId', value: productId }, { name: '@since', value: since }, ]; @@ -51,7 +50,7 @@ export async function upsert(doc: UsageDoc): Promise { return resource!; } -export async function getMonthlyUsage(userId: string): Promise { +export async function getMonthlyUsage(userId: string, productId: string): Promise { const now = new Date(); const monthStart = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-01`; @@ -66,7 +65,7 @@ export async function getMonthlyUsage(userId: string): Promise { .items.query<{ totalTokens: number; totalWords: number; totalDictations: number }>({ query, parameters: [ - { name: '@productId', value: PRODUCT_ID }, + { name: '@productId', value: productId }, { name: '@uid', value: userId }, { name: '@since', value: monthStart }, ], diff --git a/services/platform-service/src/modules/usage/routes.ts b/services/platform-service/src/modules/usage/routes.ts index 8f0c8b33..d88033c4 100644 --- a/services/platform-service/src/modules/usage/routes.ts +++ b/services/platform-service/src/modules/usage/routes.ts @@ -8,7 +8,7 @@ */ import type { FastifyInstance } from 'fastify'; -import { PRODUCT_ID } from '../../lib/product-config.js'; +import { getRequestProductId } from '../../lib/request-context.js'; import { BadRequestError } from '../../lib/errors.js'; import * as repo from './repository.js'; import { @@ -46,10 +46,12 @@ export async function usageRoutes(app: FastifyInstance) { // List usage app.get('/usage', async req => { const { userId, days = '30', limit = '100' } = req.query as Record; + const productId = getRequestProductId(req); const records = await repo.list({ userId, days: Number(days), limit: Number(limit), + productId, }); return { records, count: records.length }; }); @@ -57,7 +59,8 @@ export async function usageRoutes(app: FastifyInstance) { // Summary app.get('/usage/summary', async req => { const { userId, days = '30' } = req.query as Record; - const records = await repo.list({ userId, days: Number(days) }); + const productId = getRequestProductId(req); + const records = await repo.list({ userId, days: Number(days), productId }); const byModel: Record = {}; for (const r of records) { @@ -90,9 +93,10 @@ export async function usageRoutes(app: FastifyInstance) { throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); } const input = parsed.data; + const productId = getRequestProductId(req); const doc: UsageDoc = { id: `usg_${input.date}_${input.userId}${input.model ? `_${input.model}` : ''}`, - productId: PRODUCT_ID, + productId, ...input, createdAt: new Date().toISOString(), }; @@ -107,7 +111,8 @@ export async function usageRoutes(app: FastifyInstance) { } const { userId, plan } = parsed.data; const limits = PLAN_LIMITS[plan] ?? PLAN_LIMITS.free; - const usage = await repo.getMonthlyUsage(userId); + const productId = getRequestProductId(req); + const usage = await repo.getMonthlyUsage(userId, productId); const exceeded: string[] = []; const warnings: string[] = [];