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:
parent
b7bb6ec53f
commit
f49ef788a2
18
web/package-lock.json
generated
18
web/package-lock.json
generated
@ -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
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -13,7 +13,7 @@ export function Providers({ children }: { children: ReactNode }) {
|
||||
|
||||
useEffect(() => {
|
||||
initTelemetry();
|
||||
initFeatureFlags({ platform: 'web' });
|
||||
initFeatureFlags();
|
||||
initDiagnostics();
|
||||
}, []);
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user