diff --git a/web/package-lock.json b/web/package-lock.json index 98f15bf..d5c0cb4 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -8,6 +8,8 @@ "name": "web", "version": "0.1.0", "dependencies": { + "@bytelyst/auth-client": "file:../../learning_ai_common_plat/packages/auth-client", + "@bytelyst/telemetry-client": "file:../../learning_ai_common_plat/packages/telemetry-client", "@serwist/next": "^9.5.6", "date-fns": "^4.1.0", "idb": "^8.0.3", @@ -38,6 +40,14 @@ "vitest": "^4.0.18" } }, + "../../learning_ai_common_plat/packages/auth-client": { + "name": "@bytelyst/auth-client", + "version": "0.1.0" + }, + "../../learning_ai_common_plat/packages/telemetry-client": { + "name": "@bytelyst/telemetry-client", + "version": "0.1.0" + }, "node_modules/@acemir/cssom": { "version": "0.9.31", "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/@acemir/cssom/-/cssom-0.9.31.tgz", @@ -387,6 +397,14 @@ "specificity": "bin/cli.js" } }, + "node_modules/@bytelyst/auth-client": { + "resolved": "../../learning_ai_common_plat/packages/auth-client", + "link": true + }, + "node_modules/@bytelyst/telemetry-client": { + "resolved": "../../learning_ai_common_plat/packages/telemetry-client", + "link": true + }, "node_modules/@csstools/color-helpers": { "version": "6.0.2", "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", diff --git a/web/package.json b/web/package.json index 813c43a..b6141cf 100644 --- a/web/package.json +++ b/web/package.json @@ -14,6 +14,8 @@ "test:e2e:ui": "playwright test --ui" }, "dependencies": { + "@bytelyst/auth-client": "file:../../learning_ai_common_plat/packages/auth-client", + "@bytelyst/telemetry-client": "file:../../learning_ai_common_plat/packages/telemetry-client", "@serwist/next": "^9.5.6", "date-fns": "^4.1.0", "idb": "^8.0.3", diff --git a/web/src/lib/auth-api.ts b/web/src/lib/auth-api.ts new file mode 100644 index 0000000..6ed518f --- /dev/null +++ b/web/src/lib/auth-api.ts @@ -0,0 +1,31 @@ +/** + * Shared auth client instance for ChronoMind web. + * + * Uses @bytelyst/auth-client to eliminate hand-rolled auth API calls. + * All auth operations (login, register, forgot password, etc.) go through this client. + * Lazy-initialized to avoid localStorage access at module load time (test compat). + */ + +import { createAuthClient, type AuthClient } from '@bytelyst/auth-client'; + +export const PRODUCT_ID = 'chronomind'; + +function getBaseUrl(): string { + if (typeof window !== 'undefined' && (window as unknown as Record).__PLATFORM_URL__) { + return (window as unknown as Record).__PLATFORM_URL__ as string; + } + return process.env.NEXT_PUBLIC_PLATFORM_SERVICE_URL ?? 'https://api.chronomind.app'; +} + +let _authClient: AuthClient | null = null; + +export function getAuthClient(): AuthClient { + if (!_authClient) { + _authClient = createAuthClient({ + baseUrl: getBaseUrl(), + productId: PRODUCT_ID, + storagePrefix: 'chronomind', + }); + } + return _authClient; +} diff --git a/web/src/lib/platform-sync.ts b/web/src/lib/platform-sync.ts index 6fedde0..f215642 100644 --- a/web/src/lib/platform-sync.ts +++ b/web/src/lib/platform-sync.ts @@ -3,6 +3,8 @@ // Consumed by useSyncHook for React integration import type { Timer } from './timer-engine'; +import { getAuthClient, PRODUCT_ID as _PRODUCT_ID } from './auth-api'; +import type { AuthUser, AuthResult } from '@bytelyst/auth-client'; // ── DTOs ────────────────────────────────────────────────────── @@ -63,7 +65,7 @@ const STORAGE_KEYS = { syncEnabled: 'chronomind-platform-sync-enabled', } as const; -export const PRODUCT_ID = 'chronomind'; +export const PRODUCT_ID = _PRODUCT_ID; function getBaseUrl(): string { if (typeof window !== 'undefined' && (window as unknown as Record).__PLATFORM_URL__) { @@ -220,77 +222,44 @@ function setLastSyncDate(date: string): void { localStorage.setItem(STORAGE_KEYS.lastSync, date); } -// ── Auth Operations ────────────────────────────────────────── +// ── Auth Operations (delegated to @bytelyst/auth-client) ───── -export interface AuthUser { - id: string; - email: string; - displayName: string; - role: string; - plan: string; -} - -export interface AuthResult { - accessToken: string; - refreshToken: string; - user: AuthUser; -} +export type { AuthUser, AuthResult }; export async function loginUser(email: string, password: string): Promise { - return apiRequest('/auth/login', 'POST', { email, password, productId: PRODUCT_ID }); + return getAuthClient().login(email, password); } -export async function registerUser( - email: string, - password: string, - displayName: string -): Promise { - return apiRequest('/auth/register', 'POST', { - email, - password, - displayName, - productId: PRODUCT_ID, - }); +export async function registerUser(email: string, password: string, displayName: string): Promise { + return getAuthClient().register(email, password, displayName); } export async function getMe(): Promise { - return apiRequest('/auth/me', 'GET'); + return getAuthClient().getMe(); } export async function forgotPassword(email: string): Promise<{ message: string }> { - return apiRequest<{ message: string }>('/auth/forgot-password', 'POST', { - email, - productId: PRODUCT_ID, - }); + return getAuthClient().forgotPassword(email); } export async function resetPassword(token: string, newPassword: string): Promise<{ message: string }> { - return apiRequest<{ message: string }>('/auth/reset-password', 'POST', { token, newPassword }); + return getAuthClient().resetPassword(token, newPassword); } -export async function changePassword( - currentPassword: string, - newPassword: string -): Promise<{ message: string }> { - return apiRequest<{ message: string }>('/auth/change-password', 'POST', { - currentPassword, - newPassword, - }); +export async function changePassword(currentPassword: string, newPassword: string): Promise<{ message: string }> { + return getAuthClient().changePassword(currentPassword, newPassword); } export async function verifyEmail(token: string): Promise<{ message: string }> { - return apiRequest<{ message: string }>('/auth/verify-email', 'POST', { token }); + return getAuthClient().verifyEmail(token); } export async function resendVerification(email: string): Promise<{ message: string }> { - return apiRequest<{ message: string }>('/auth/resend-verification', 'POST', { - email, - productId: PRODUCT_ID, - }); + return getAuthClient().resendVerification(email); } export async function deleteAccount(password: string): Promise<{ message: string }> { - return apiRequest<{ message: string }>('/auth/account', 'DELETE', { password }); + return getAuthClient().deleteAccount(password); } // ── Sync Operations ─────────────────────────────────────────── diff --git a/web/src/lib/telemetry.ts b/web/src/lib/telemetry.ts index 087028a..ece5f81 100644 --- a/web/src/lib/telemetry.ts +++ b/web/src/lib/telemetry.ts @@ -1,65 +1,26 @@ // ── Platform Telemetry Client ───────────────────────────────── -// Sends lightweight events to platform-service telemetry endpoint. -// Runs in the browser only. Privacy: no PII, only action names + timing. +// Delegates to @bytelyst/telemetry-client shared package. +// Privacy: no PII, only action names + timing. -import { PRODUCT_ID } from './platform-sync'; +import { createTelemetryClient, type TelemetryClient } from '@bytelyst/telemetry-client'; +import { PRODUCT_ID } from './auth-api'; -const PLATFORM = 'web'; -const OS_FAMILY = 'other'; -const CHANNEL = 'pwa'; -const MAX_QUEUE = 50; -const FLUSH_INTERVAL_MS = 30_000; +let _client: TelemetryClient | null = null; -interface TelemetryEvent { - id: string; - productId: string; - anonymousInstallId: string; - sessionId: string; - platform: string; - channel: string; - osFamily: string; - osVersion: string; - appVersion: string; - buildNumber: string; - releaseChannel: string; - eventType: string; - module: string; - eventName: string; - feature?: string; - message?: string; - tags?: Record; - metrics?: Record; - occurredAt: string; -} - -let queue: TelemetryEvent[] = []; -let sessionId = ''; -let installId = ''; -let flushTimer: ReturnType | null = null; -let baseUrl = ''; - -function uuid(): string { - if (typeof crypto?.randomUUID === 'function') return crypto.randomUUID(); - return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { - const r = (Math.random() * 16) | 0; - return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16); - }); -} - -function getInstallId(): string { - if (installId) return installId; - try { - const stored = localStorage.getItem('chronomind_telemetry_install_id'); - if (stored) { - installId = stored; - return installId; - } - installId = uuid(); - localStorage.setItem('chronomind_telemetry_install_id', installId); - } catch { - installId = uuid(); +function getClient(): TelemetryClient { + if (!_client) { + _client = createTelemetryClient({ + productId: PRODUCT_ID, + baseUrl: process.env.NEXT_PUBLIC_PLATFORM_SERVICE_URL ?? 'https://api.chronomind.app', + platform: 'web', + channel: 'pwa', + transport: 'fetch', + appVersion: '0.1.0', + buildNumber: '1', + releaseChannel: 'beta', + }); } - return installId; + return _client; } export function trackEvent( @@ -73,35 +34,7 @@ export function trackEvent( metrics?: Record; } ): void { - if (typeof window === 'undefined') return; - - const event: TelemetryEvent = { - id: uuid(), - productId: PRODUCT_ID, - anonymousInstallId: getInstallId(), - sessionId, - platform: PLATFORM, - channel: CHANNEL, - osFamily: OS_FAMILY, - osVersion: navigator.userAgent.substring(0, 128), - appVersion: '0.1.0', - buildNumber: '1', - releaseChannel: 'beta', - eventType, - module, - eventName, - occurredAt: new Date().toISOString(), - ...options, - }; - - queue.push(event); - if (queue.length > MAX_QUEUE) { - queue = queue.slice(-MAX_QUEUE); - } - - if (queue.length >= 10) { - flush(); - } + getClient().trackEvent(eventType, module, eventName, options); } export function trackTimerEvent(eventName: string, tags?: Record, metrics?: Record): void { @@ -113,47 +46,9 @@ export function trackPageView(path: string): void { } export function flush(): void { - if (typeof window === 'undefined' || queue.length === 0 || !baseUrl) return; - - const events = [...queue]; - queue = []; - - const body = JSON.stringify({ productId: PRODUCT_ID, events }); - const url = `${baseUrl}/telemetry/events`; - - const headers: Record = { - 'Content-Type': 'application/json', - 'x-product-id': PRODUCT_ID, - 'x-request-id': uuid(), - }; - - try { - // sendBeacon doesn't support custom headers — use fetch with keepalive - fetch(url, { - method: 'POST', - headers, - body, - keepalive: true, - }).catch(() => {}); - } catch { - // Best effort — telemetry is non-critical - } + getClient().flush(); } export function initTelemetry(): void { - if (typeof window === 'undefined') return; - - baseUrl = process.env.NEXT_PUBLIC_PLATFORM_SERVICE_URL ?? 'https://api.chronomind.app'; - sessionId = uuid(); - - window.addEventListener('visibilitychange', () => { - if (document.visibilityState === 'hidden') { - flush(); - } - }); - - if (flushTimer) clearInterval(flushTimer); - flushTimer = setInterval(flush, FLUSH_INTERVAL_MS); - - trackEvent('info', 'app_lifecycle', 'session_started'); + getClient().init(); }