feat(web): migrate billing-client + feature-flags to shared packages

- billing-client.ts: hand-rolled fetch → @bytelyst/subscription-client
  wrapper with backwards-compatible Subscription type adapter
- feature-flags.ts: hand-rolled polling → @bytelyst/feature-flag-client
  (71→42 lines, same public API)
- providers.tsx: remove platform param (now configured at client level)
- ChronoMind-specific usage API stays as local fetch call
This commit is contained in:
saravanakumardb1 2026-03-19 16:52:47 -07:00
parent b7bb6ec53f
commit f49ef788a2
5 changed files with 93 additions and 78 deletions

18
web/package-lock.json generated
View File

@ -11,7 +11,9 @@
"@bytelyst/api-client": "file:../../learning_ai_common_plat/packages/api-client",
"@bytelyst/auth-client": "file:../../learning_ai_common_plat/packages/auth-client",
"@bytelyst/diagnostics-client": "file:../../learning_ai_common_plat/packages/diagnostics-client",
"@bytelyst/feature-flag-client": "file:../../learning_ai_common_plat/packages/feature-flag-client",
"@bytelyst/react-auth": "file:../../learning_ai_common_plat/packages/react-auth",
"@bytelyst/subscription-client": "file:../../learning_ai_common_plat/packages/subscription-client",
"@bytelyst/telemetry-client": "file:../../learning_ai_common_plat/packages/telemetry-client",
"@serwist/next": "^9.5.6",
"date-fns": "^4.1.0",
@ -66,6 +68,10 @@
"zod": "^3.22.0"
}
},
"../../learning_ai_common_plat/packages/feature-flag-client": {
"name": "@bytelyst/feature-flag-client",
"version": "0.1.0"
},
"../../learning_ai_common_plat/packages/react-auth": {
"name": "@bytelyst/react-auth",
"version": "0.1.0",
@ -84,6 +90,10 @@
"react": ">=18.0.0"
}
},
"../../learning_ai_common_plat/packages/subscription-client": {
"name": "@bytelyst/subscription-client",
"version": "0.1.0"
},
"../../learning_ai_common_plat/packages/telemetry-client": {
"name": "@bytelyst/telemetry-client",
"version": "0.1.0"
@ -449,10 +459,18 @@
"resolved": "../../learning_ai_common_plat/packages/diagnostics-client",
"link": true
},
"node_modules/@bytelyst/feature-flag-client": {
"resolved": "../../learning_ai_common_plat/packages/feature-flag-client",
"link": true
},
"node_modules/@bytelyst/react-auth": {
"resolved": "../../learning_ai_common_plat/packages/react-auth",
"link": true
},
"node_modules/@bytelyst/subscription-client": {
"resolved": "../../learning_ai_common_plat/packages/subscription-client",
"link": true
},
"node_modules/@bytelyst/telemetry-client": {
"resolved": "../../learning_ai_common_plat/packages/telemetry-client",
"link": true

View File

@ -18,6 +18,8 @@
"@bytelyst/auth-client": "file:../../learning_ai_common_plat/packages/auth-client",
"@bytelyst/react-auth": "file:../../learning_ai_common_plat/packages/react-auth",
"@bytelyst/diagnostics-client": "file:../../learning_ai_common_plat/packages/diagnostics-client",
"@bytelyst/feature-flag-client": "file:../../learning_ai_common_plat/packages/feature-flag-client",
"@bytelyst/subscription-client": "file:../../learning_ai_common_plat/packages/subscription-client",
"@bytelyst/telemetry-client": "file:../../learning_ai_common_plat/packages/telemetry-client",
"@serwist/next": "^9.5.6",
"date-fns": "^4.1.0",

View File

@ -13,7 +13,7 @@ export function Providers({ children }: { children: ReactNode }) {
useEffect(() => {
initTelemetry();
initFeatureFlags({ platform: 'web' });
initFeatureFlags();
initDiagnostics();
}, []);

View File

@ -1,33 +1,32 @@
/**
* Billing / Subscription client stub for ChronoMind.
* Billing / Subscription client thin wrapper over @bytelyst/subscription-client.
*
* Calls platform-service subscription + usage endpoints via the auth-api base URL.
* Delegates subscription operations to the shared package.
* Keeps ChronoMind-specific usage API as a local fetch call.
* Client-side only uses the stored auth token for authorization.
*/
import { PRODUCT_ID, getBaseUrl } from './auth-api';
import { createSubscriptionClient } from '@bytelyst/subscription-client';
import type { SubscriptionDoc } from '@bytelyst/subscription-client';
import { PRODUCT_ID, getBaseUrl, getAuthClient } from './auth-api';
function getToken(): string | null {
if (typeof window === 'undefined') return null;
return localStorage.getItem('chronomind_access_token');
// ── Shared client ──────────────────────────────────────────────────
let _client: ReturnType<typeof createSubscriptionClient> | null = null;
function getClient() {
if (!_client) {
_client = createSubscriptionClient({
baseUrl: getBaseUrl(),
productId: PRODUCT_ID,
userId: 'me',
getAccessToken: () => getAuthClient().getAccessToken() ?? '',
});
}
return _client;
}
async function billingFetch<T>(path: string, options?: RequestInit): Promise<T> {
const token = getToken();
const res = await fetch(`${getBaseUrl()}${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
'x-product-id': PRODUCT_ID,
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(options?.headers as Record<string, string>),
},
});
if (!res.ok) throw new Error(`Billing API error: ${res.status}`);
return res.json();
}
// ── Types ──────────────────────────────────────────────────────────
// ── Types (backwards-compatible) ───────────────────────────────────
export interface Subscription {
id: string;
@ -44,28 +43,44 @@ export interface Subscription {
updatedAt: string;
}
function toSubscription(doc: SubscriptionDoc): Subscription {
return {
id: doc.id,
productId: doc.productId ?? PRODUCT_ID,
userId: doc.userId,
plan: doc.plan as Subscription['plan'],
status: doc.status as Subscription['status'],
currentPeriodStart: doc.currentPeriodStart,
currentPeriodEnd: doc.currentPeriodEnd,
cancelAtPeriodEnd: doc.cancelAtPeriodEnd,
stripeCustomerId: doc.stripeCustomerId,
stripeSubscriptionId: doc.stripeSubscriptionId,
createdAt: doc.createdAt,
updatedAt: doc.updatedAt,
};
}
// ── Subscription API ───────────────────────────────────────────────
export async function getMySubscription(userId: string): Promise<Subscription | null> {
export async function getMySubscription(_userId?: string): Promise<Subscription | null> {
try {
return await billingFetch<Subscription>(`/subscriptions/${userId}`);
const doc = await getClient().getMySubscription();
return doc ? toSubscription(doc) : null;
} catch {
return null;
}
}
export async function cancelSubscription(userId: string): Promise<Subscription | null> {
export async function cancelSubscription(_userId?: string): Promise<Subscription | null> {
try {
return await billingFetch<Subscription>(`/subscriptions/${userId}`, {
method: 'PUT',
body: JSON.stringify({ cancelAtPeriodEnd: true }),
});
const doc = await getClient().cancelSubscription();
return toSubscription(doc);
} catch {
return null;
}
}
// ── Usage API ──────────────────────────────────────────────────────
// ── Usage API (ChronoMind-specific, not in shared client) ──────────
export interface UsageSummary {
timersCreated: number;
@ -75,8 +90,17 @@ export interface UsageSummary {
export async function getUsageSummary(userId: string, days = 30): Promise<UsageSummary | null> {
try {
const data = await billingFetch<{ summary: UsageSummary }>(`/usage/summary?userId=${userId}&days=${days}`);
return data.summary as UsageSummary;
const token = getAuthClient().getAccessToken();
const res = await fetch(`${getBaseUrl()}/usage/summary?userId=${userId}&days=${days}`, {
headers: {
'Content-Type': 'application/json',
'x-product-id': PRODUCT_ID,
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
});
if (!res.ok) return null;
const data = await res.json() as { summary: UsageSummary };
return data.summary;
} catch {
return null;
}

View File

@ -1,70 +1,41 @@
/**
* Feature flag client polls platform-service /flags/poll endpoint.
* Feature flag client thin wrapper over @bytelyst/feature-flag-client.
*
* Flags are fetched on init and cached in memory. Re-polls on a configurable
* interval (default 5 min). Consumers call `isEnabled('flag_key')`.
* Delegates polling and caching to the shared package.
* Preserves the same public API for existing consumers.
*
* Privacy: sends userId + platform only. No PII.
*/
import { PRODUCT_ID } from './auth-api';
import { createFeatureFlagClient } from '@bytelyst/feature-flag-client';
import { PRODUCT_ID, getBaseUrl } from './auth-api';
const PLATFORM_URL = process.env.NEXT_PUBLIC_PLATFORM_SERVICE_URL ?? 'https://api.chronomind.app';
const POLL_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
let _flags: Record<string, boolean> = {};
let _initialized = false;
let _intervalId: ReturnType<typeof setInterval> | null = null;
interface PollParams {
userId?: string;
platform?: string;
}
async function fetchFlags(params: PollParams): Promise<Record<string, boolean>> {
try {
const qs = new URLSearchParams();
if (params.userId) qs.set('userId', params.userId);
if (params.platform) qs.set('platform', params.platform);
const res = await fetch(`${PLATFORM_URL}/api/flags/poll?${qs.toString()}`, {
headers: { 'x-product-id': PRODUCT_ID },
});
if (!res.ok) return _flags;
const data = await res.json();
return (data as { flags: Record<string, boolean> }).flags ?? {};
} catch {
return _flags;
}
}
const client = createFeatureFlagClient({
baseUrl: getBaseUrl(),
productId: PRODUCT_ID,
platform: 'web',
pollIntervalMs: 5 * 60 * 1000,
});
/**
* Initialize the flag client. Call once on app startup.
* Fetches flags immediately and starts polling.
*/
export async function initFeatureFlags(params: PollParams = {}): Promise<void> {
if (_initialized) return;
_initialized = true;
const pollParams: PollParams = { platform: 'web', ...params };
_flags = await fetchFlags(pollParams);
_intervalId = setInterval(async () => {
_flags = await fetchFlags(pollParams);
}, POLL_INTERVAL_MS);
export async function initFeatureFlags(params: { userId?: string } = {}): Promise<void> {
await client.init(params);
}
/** Check if a feature flag is enabled. Returns false if not found or not initialized. */
export function isEnabled(key: string): boolean {
return _flags[key] === true;
return client.isEnabled(key);
}
/** Get all currently cached flags. */
export function getAllFlags(): Readonly<Record<string, boolean>> {
return _flags;
return client.getAllFlags();
}
/** Stop polling and reset state. Useful for tests. */
export function resetFeatureFlags(): void {
if (_intervalId) clearInterval(_intervalId);
_intervalId = null;
_flags = {};
_initialized = false;
client.stop();
}