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/api-client": "file:../../learning_ai_common_plat/packages/api-client",
|
||||||
"@bytelyst/auth-client": "file:../../learning_ai_common_plat/packages/auth-client",
|
"@bytelyst/auth-client": "file:../../learning_ai_common_plat/packages/auth-client",
|
||||||
"@bytelyst/diagnostics-client": "file:../../learning_ai_common_plat/packages/diagnostics-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/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",
|
"@bytelyst/telemetry-client": "file:../../learning_ai_common_plat/packages/telemetry-client",
|
||||||
"@serwist/next": "^9.5.6",
|
"@serwist/next": "^9.5.6",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
@ -66,6 +68,10 @@
|
|||||||
"zod": "^3.22.0"
|
"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": {
|
"../../learning_ai_common_plat/packages/react-auth": {
|
||||||
"name": "@bytelyst/react-auth",
|
"name": "@bytelyst/react-auth",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
@ -84,6 +90,10 @@
|
|||||||
"react": ">=18.0.0"
|
"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": {
|
"../../learning_ai_common_plat/packages/telemetry-client": {
|
||||||
"name": "@bytelyst/telemetry-client",
|
"name": "@bytelyst/telemetry-client",
|
||||||
"version": "0.1.0"
|
"version": "0.1.0"
|
||||||
@ -449,10 +459,18 @@
|
|||||||
"resolved": "../../learning_ai_common_plat/packages/diagnostics-client",
|
"resolved": "../../learning_ai_common_plat/packages/diagnostics-client",
|
||||||
"link": true
|
"link": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@bytelyst/feature-flag-client": {
|
||||||
|
"resolved": "../../learning_ai_common_plat/packages/feature-flag-client",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
"node_modules/@bytelyst/react-auth": {
|
"node_modules/@bytelyst/react-auth": {
|
||||||
"resolved": "../../learning_ai_common_plat/packages/react-auth",
|
"resolved": "../../learning_ai_common_plat/packages/react-auth",
|
||||||
"link": true
|
"link": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@bytelyst/subscription-client": {
|
||||||
|
"resolved": "../../learning_ai_common_plat/packages/subscription-client",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
"node_modules/@bytelyst/telemetry-client": {
|
"node_modules/@bytelyst/telemetry-client": {
|
||||||
"resolved": "../../learning_ai_common_plat/packages/telemetry-client",
|
"resolved": "../../learning_ai_common_plat/packages/telemetry-client",
|
||||||
"link": true
|
"link": true
|
||||||
|
|||||||
@ -18,6 +18,8 @@
|
|||||||
"@bytelyst/auth-client": "file:../../learning_ai_common_plat/packages/auth-client",
|
"@bytelyst/auth-client": "file:../../learning_ai_common_plat/packages/auth-client",
|
||||||
"@bytelyst/react-auth": "file:../../learning_ai_common_plat/packages/react-auth",
|
"@bytelyst/react-auth": "file:../../learning_ai_common_plat/packages/react-auth",
|
||||||
"@bytelyst/diagnostics-client": "file:../../learning_ai_common_plat/packages/diagnostics-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/subscription-client": "file:../../learning_ai_common_plat/packages/subscription-client",
|
||||||
"@bytelyst/telemetry-client": "file:../../learning_ai_common_plat/packages/telemetry-client",
|
"@bytelyst/telemetry-client": "file:../../learning_ai_common_plat/packages/telemetry-client",
|
||||||
"@serwist/next": "^9.5.6",
|
"@serwist/next": "^9.5.6",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
|||||||
@ -13,7 +13,7 @@ export function Providers({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
initTelemetry();
|
initTelemetry();
|
||||||
initFeatureFlags({ platform: 'web' });
|
initFeatureFlags();
|
||||||
initDiagnostics();
|
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.
|
* 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 {
|
// ── Shared client ──────────────────────────────────────────────────
|
||||||
if (typeof window === 'undefined') return null;
|
|
||||||
return localStorage.getItem('chronomind_access_token');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function billingFetch<T>(path: string, options?: RequestInit): Promise<T> {
|
let _client: ReturnType<typeof createSubscriptionClient> | null = null;
|
||||||
const token = getToken();
|
|
||||||
const res = await fetch(`${getBaseUrl()}${path}`, {
|
function getClient() {
|
||||||
...options,
|
if (!_client) {
|
||||||
headers: {
|
_client = createSubscriptionClient({
|
||||||
'Content-Type': 'application/json',
|
baseUrl: getBaseUrl(),
|
||||||
'x-product-id': PRODUCT_ID,
|
productId: PRODUCT_ID,
|
||||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
userId: 'me',
|
||||||
...(options?.headers as Record<string, string>),
|
getAccessToken: () => getAuthClient().getAccessToken() ?? '',
|
||||||
},
|
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(`Billing API error: ${res.status}`);
|
}
|
||||||
return res.json();
|
return _client;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Types ──────────────────────────────────────────────────────────
|
// ── Types (backwards-compatible) ───────────────────────────────────
|
||||||
|
|
||||||
export interface Subscription {
|
export interface Subscription {
|
||||||
id: string;
|
id: string;
|
||||||
@ -44,28 +43,44 @@ export interface Subscription {
|
|||||||
updatedAt: string;
|
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 ───────────────────────────────────────────────
|
// ── Subscription API ───────────────────────────────────────────────
|
||||||
|
|
||||||
export async function getMySubscription(userId: string): Promise<Subscription | null> {
|
export async function getMySubscription(_userId?: string): Promise<Subscription | null> {
|
||||||
try {
|
try {
|
||||||
return await billingFetch<Subscription>(`/subscriptions/${userId}`);
|
const doc = await getClient().getMySubscription();
|
||||||
|
return doc ? toSubscription(doc) : null;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function cancelSubscription(userId: string): Promise<Subscription | null> {
|
export async function cancelSubscription(_userId?: string): Promise<Subscription | null> {
|
||||||
try {
|
try {
|
||||||
return await billingFetch<Subscription>(`/subscriptions/${userId}`, {
|
const doc = await getClient().cancelSubscription();
|
||||||
method: 'PUT',
|
return toSubscription(doc);
|
||||||
body: JSON.stringify({ cancelAtPeriodEnd: true }),
|
|
||||||
});
|
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Usage API ──────────────────────────────────────────────────────
|
// ── Usage API (ChronoMind-specific, not in shared client) ──────────
|
||||||
|
|
||||||
export interface UsageSummary {
|
export interface UsageSummary {
|
||||||
timersCreated: number;
|
timersCreated: number;
|
||||||
@ -75,8 +90,17 @@ export interface UsageSummary {
|
|||||||
|
|
||||||
export async function getUsageSummary(userId: string, days = 30): Promise<UsageSummary | null> {
|
export async function getUsageSummary(userId: string, days = 30): Promise<UsageSummary | null> {
|
||||||
try {
|
try {
|
||||||
const data = await billingFetch<{ summary: UsageSummary }>(`/usage/summary?userId=${userId}&days=${days}`);
|
const token = getAuthClient().getAccessToken();
|
||||||
return data.summary as UsageSummary;
|
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 {
|
} catch {
|
||||||
return null;
|
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
|
* Delegates polling and caching to the shared package.
|
||||||
* interval (default 5 min). Consumers call `isEnabled('flag_key')`.
|
* Preserves the same public API for existing consumers.
|
||||||
*
|
*
|
||||||
* Privacy: sends userId + platform only. No PII.
|
* 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 client = createFeatureFlagClient({
|
||||||
const POLL_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
|
baseUrl: getBaseUrl(),
|
||||||
|
productId: PRODUCT_ID,
|
||||||
let _flags: Record<string, boolean> = {};
|
platform: 'web',
|
||||||
let _initialized = false;
|
pollIntervalMs: 5 * 60 * 1000,
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize the flag client. Call once on app startup.
|
* Initialize the flag client. Call once on app startup.
|
||||||
* Fetches flags immediately and starts polling.
|
* Fetches flags immediately and starts polling.
|
||||||
*/
|
*/
|
||||||
export async function initFeatureFlags(params: PollParams = {}): Promise<void> {
|
export async function initFeatureFlags(params: { userId?: string } = {}): Promise<void> {
|
||||||
if (_initialized) return;
|
await client.init(params);
|
||||||
_initialized = true;
|
|
||||||
const pollParams: PollParams = { platform: 'web', ...params };
|
|
||||||
_flags = await fetchFlags(pollParams);
|
|
||||||
_intervalId = setInterval(async () => {
|
|
||||||
_flags = await fetchFlags(pollParams);
|
|
||||||
}, POLL_INTERVAL_MS);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Check if a feature flag is enabled. Returns false if not found or not initialized. */
|
/** Check if a feature flag is enabled. Returns false if not found or not initialized. */
|
||||||
export function isEnabled(key: string): boolean {
|
export function isEnabled(key: string): boolean {
|
||||||
return _flags[key] === true;
|
return client.isEnabled(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get all currently cached flags. */
|
/** Get all currently cached flags. */
|
||||||
export function getAllFlags(): Readonly<Record<string, boolean>> {
|
export function getAllFlags(): Readonly<Record<string, boolean>> {
|
||||||
return _flags;
|
return client.getAllFlags();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Stop polling and reset state. Useful for tests. */
|
/** Stop polling and reset state. Useful for tests. */
|
||||||
export function resetFeatureFlags(): void {
|
export function resetFeatureFlags(): void {
|
||||||
if (_intervalId) clearInterval(_intervalId);
|
client.stop();
|
||||||
_intervalId = null;
|
|
||||||
_flags = {};
|
|
||||||
_initialized = false;
|
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user