@clarityops/preferences 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,443 @@
1
+ import type {
2
+ UserPreferences,
3
+ PrepareAvatarUploadResponse,
4
+ ConfirmAvatarUploadResponse,
5
+ MyAvatarResponse,
6
+ PreferencesConfig,
7
+ } from './types';
8
+
9
+ /**
10
+ * Validate if a token is a well-formed JWT with 3 segments
11
+ */
12
+ function isValidJwtFormat(token: string | null | undefined): boolean {
13
+ if (!token || typeof token !== 'string') return false;
14
+ // Check for placeholder values
15
+ if (token === 'null' || token === 'undefined' || token.trim() === '') return false;
16
+ // JWT must have exactly 3 segments
17
+ const segments = token.split('.');
18
+ return segments.length === 3;
19
+ }
20
+
21
+ /**
22
+ * Check if a JWT token is expired
23
+ */
24
+ function isTokenExpired(token: string): boolean {
25
+ try {
26
+ const payload = JSON.parse(atob(token.split('.')[1]));
27
+ const currentTime = Math.floor(Date.now() / 1000);
28
+ return payload.exp < currentTime;
29
+ } catch {
30
+ return true;
31
+ }
32
+ }
33
+
34
+ // Shared refresh lock to prevent concurrent refresh attempts across service instances
35
+ let refreshInProgress: Promise<string | null> | null = null;
36
+ let lastRefreshTime = 0;
37
+ const REFRESH_DEBOUNCE_MS = 5000; // Minimum 5 seconds between refresh attempts
38
+
39
+ /**
40
+ * Service class for user preferences and avatar management
41
+ * Shared across InsightForge and InsightFlow platforms
42
+ */
43
+ export class PreferencesService {
44
+ private baseUrl: string;
45
+ private brandingUrl: string;
46
+ private getToken: () => string | null;
47
+ private onTokenInvalid?: () => Promise<string | null>;
48
+
49
+ constructor(config: PreferencesConfig) {
50
+ this.baseUrl = config.apiBaseUrl;
51
+ this.brandingUrl = config.brandingUrl;
52
+ this.getToken = config.getToken;
53
+ this.onTokenInvalid = config.onTokenInvalid;
54
+ }
55
+
56
+ /**
57
+ * Update the config (e.g., when token changes)
58
+ */
59
+ updateConfig(config: Partial<PreferencesConfig>) {
60
+ if (config.getToken) {
61
+ this.getToken = config.getToken;
62
+ }
63
+ if (config.onTokenInvalid !== undefined) {
64
+ this.onTokenInvalid = config.onTokenInvalid;
65
+ }
66
+ if (config.apiBaseUrl) {
67
+ this.baseUrl = config.apiBaseUrl;
68
+ }
69
+ if (config.brandingUrl) {
70
+ this.brandingUrl = config.brandingUrl;
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Refresh token with debounce to prevent multiple simultaneous refresh attempts
76
+ */
77
+ private async refreshWithDebounce(): Promise<string | null> {
78
+ const now = Date.now();
79
+
80
+ // If a refresh is already in progress, wait for it
81
+ if (refreshInProgress) {
82
+ console.log('[PreferencesService] ⏳ Waiting for existing refresh to complete...');
83
+ return refreshInProgress;
84
+ }
85
+
86
+ // Debounce: if we just refreshed, get current token from localStorage
87
+ if (now - lastRefreshTime < REFRESH_DEBOUNCE_MS) {
88
+ console.log('[PreferencesService] ⏳ Recent refresh detected, using current token');
89
+ return this.getToken();
90
+ }
91
+
92
+ // Start a new refresh
93
+ if (this.onTokenInvalid) {
94
+ console.log('[PreferencesService] 🔄 Starting token refresh...');
95
+ lastRefreshTime = now;
96
+ refreshInProgress = this.onTokenInvalid().finally(() => {
97
+ refreshInProgress = null;
98
+ });
99
+ return refreshInProgress;
100
+ }
101
+
102
+ return null;
103
+ }
104
+
105
+ /**
106
+ * Get a valid token, refreshing if necessary with debounce protection
107
+ */
108
+ private async getValidToken(): Promise<string> {
109
+ let token = this.getToken();
110
+
111
+ // Check if token is valid format
112
+ if (!isValidJwtFormat(token)) {
113
+ console.warn('[PreferencesService] Token is missing or malformed, attempting refresh...');
114
+ token = await this.refreshWithDebounce();
115
+ if (!isValidJwtFormat(token)) {
116
+ throw new Error('Authentication required - no valid token available');
117
+ }
118
+ }
119
+
120
+ // Check if token is expired
121
+ if (isTokenExpired(token!)) {
122
+ console.warn('[PreferencesService] Token is expired, attempting refresh...');
123
+ token = await this.refreshWithDebounce();
124
+ if (!token || isTokenExpired(token)) {
125
+ throw new Error('Session expired - please log in again');
126
+ }
127
+ }
128
+
129
+ return token!;
130
+ }
131
+
132
+ private async request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
133
+ const token = await this.getValidToken();
134
+
135
+ const response = await fetch(`${this.baseUrl}${endpoint}`, {
136
+ ...options,
137
+ headers: {
138
+ 'Authorization': `Bearer ${token}`,
139
+ 'Content-Type': 'application/json',
140
+ ...options.headers,
141
+ },
142
+ });
143
+
144
+ if (!response.ok) {
145
+ const error = await response.json().catch(() => ({ error: 'Request failed' }));
146
+
147
+ // If we get a 401, try to refresh and retry once
148
+ if (response.status === 401 && this.onTokenInvalid) {
149
+ console.warn('[PreferencesService] Got 401, attempting token refresh and retry...');
150
+ const newToken = await this.onTokenInvalid();
151
+ if (newToken && isValidJwtFormat(newToken)) {
152
+ const retryResponse = await fetch(`${this.baseUrl}${endpoint}`, {
153
+ ...options,
154
+ headers: {
155
+ 'Authorization': `Bearer ${newToken}`,
156
+ 'Content-Type': 'application/json',
157
+ ...options.headers,
158
+ },
159
+ });
160
+
161
+ if (retryResponse.ok) {
162
+ return retryResponse.json();
163
+ }
164
+ }
165
+ }
166
+
167
+ throw new Error(error.error || error.message || 'Request failed');
168
+ }
169
+
170
+ const data = await response.json();
171
+ return data;
172
+ }
173
+
174
+ /**
175
+ * Get user preferences
176
+ */
177
+ async getPreferences(): Promise<{ data: { preferences: UserPreferences } }> {
178
+ return this.request('/forge/users/me/preferences');
179
+ }
180
+
181
+ /**
182
+ * Update preferences (merge update)
183
+ */
184
+ async updatePreferences(preferences: Partial<UserPreferences>): Promise<{ data: { preferences: UserPreferences }; message?: string }> {
185
+ return this.request('/forge/users/me/preferences', {
186
+ method: 'PATCH',
187
+ body: JSON.stringify(preferences),
188
+ });
189
+ }
190
+
191
+ /**
192
+ * Replace all preferences
193
+ */
194
+ async replacePreferences(preferences: UserPreferences): Promise<{ data: { preferences: UserPreferences }; message?: string }> {
195
+ return this.request('/forge/users/me/preferences', {
196
+ method: 'PUT',
197
+ body: JSON.stringify(preferences),
198
+ });
199
+ }
200
+
201
+ /**
202
+ * Step 1: Prepare avatar upload - get signed URL for GCS
203
+ */
204
+ async prepareAvatarUpload(filename: string, mimeType: string): Promise<PrepareAvatarUploadResponse> {
205
+ const token = await this.getValidToken();
206
+
207
+ const response = await fetch(`${this.brandingUrl}/branding/prepare-avatar-upload`, {
208
+ method: 'POST',
209
+ headers: {
210
+ 'Authorization': `Bearer ${token}`,
211
+ 'Content-Type': 'application/json',
212
+ },
213
+ body: JSON.stringify({
214
+ filename,
215
+ mime_type: mimeType,
216
+ }),
217
+ });
218
+
219
+ if (!response.ok) {
220
+ const error = await response.json().catch(() => ({ error: 'Failed to prepare upload' }));
221
+
222
+ // If we get a 401, try to refresh and retry once
223
+ if (response.status === 401 && this.onTokenInvalid) {
224
+ console.warn('[PreferencesService] Got 401 on prepareAvatarUpload, attempting refresh...');
225
+ const newToken = await this.onTokenInvalid();
226
+ if (newToken && isValidJwtFormat(newToken)) {
227
+ const retryResponse = await fetch(`${this.brandingUrl}/branding/prepare-avatar-upload`, {
228
+ method: 'POST',
229
+ headers: {
230
+ 'Authorization': `Bearer ${newToken}`,
231
+ 'Content-Type': 'application/json',
232
+ },
233
+ body: JSON.stringify({
234
+ filename,
235
+ mime_type: mimeType,
236
+ }),
237
+ });
238
+
239
+ if (retryResponse.ok) {
240
+ const data = await retryResponse.json();
241
+ if (data.success) return data.data;
242
+ }
243
+ }
244
+ }
245
+
246
+ throw new Error(error.error || error.message || 'Failed to prepare avatar upload');
247
+ }
248
+
249
+ const data = await response.json();
250
+ if (!data.success) {
251
+ throw new Error(data.error || 'Failed to prepare avatar upload');
252
+ }
253
+
254
+ return data.data;
255
+ }
256
+
257
+ /**
258
+ * Step 2: Upload file directly to GCS
259
+ */
260
+ async uploadToGCS(uploadUrl: string, file: File): Promise<void> {
261
+ const response = await fetch(uploadUrl, {
262
+ method: 'PUT',
263
+ headers: {
264
+ 'Content-Type': file.type,
265
+ },
266
+ body: file,
267
+ });
268
+
269
+ if (!response.ok) {
270
+ throw new Error('Failed to upload file to storage');
271
+ }
272
+ }
273
+
274
+ /**
275
+ * Step 3: Confirm avatar upload
276
+ */
277
+ async confirmAvatarUpload(storagePath: string): Promise<ConfirmAvatarUploadResponse> {
278
+ const token = await this.getValidToken();
279
+
280
+ const response = await fetch(`${this.brandingUrl}/branding/confirm-avatar-upload`, {
281
+ method: 'POST',
282
+ headers: {
283
+ 'Authorization': `Bearer ${token}`,
284
+ 'Content-Type': 'application/json',
285
+ },
286
+ body: JSON.stringify({
287
+ storage_path: storagePath,
288
+ }),
289
+ });
290
+
291
+ if (!response.ok) {
292
+ const error = await response.json().catch(() => ({ error: 'Failed to confirm upload' }));
293
+
294
+ // If we get a 401, try to refresh and retry once
295
+ if (response.status === 401 && this.onTokenInvalid) {
296
+ console.warn('[PreferencesService] Got 401 on confirmAvatarUpload, attempting refresh...');
297
+ const newToken = await this.onTokenInvalid();
298
+ if (newToken && isValidJwtFormat(newToken)) {
299
+ const retryResponse = await fetch(`${this.brandingUrl}/branding/confirm-avatar-upload`, {
300
+ method: 'POST',
301
+ headers: {
302
+ 'Authorization': `Bearer ${newToken}`,
303
+ 'Content-Type': 'application/json',
304
+ },
305
+ body: JSON.stringify({
306
+ storage_path: storagePath,
307
+ }),
308
+ });
309
+
310
+ if (retryResponse.ok) {
311
+ const data = await retryResponse.json();
312
+ if (data.success) return data.data;
313
+ }
314
+ }
315
+ }
316
+
317
+ throw new Error(error.error || error.message || 'Failed to confirm avatar upload');
318
+ }
319
+
320
+ const data = await response.json();
321
+ if (!data.success) {
322
+ throw new Error(data.error || 'Failed to confirm avatar upload');
323
+ }
324
+
325
+ return data.data;
326
+ }
327
+
328
+ /**
329
+ * Get current user's avatar
330
+ */
331
+ async getMyAvatar(): Promise<MyAvatarResponse> {
332
+ const token = await this.getValidToken();
333
+
334
+ const response = await fetch(`${this.brandingUrl}/branding/my-avatar`, {
335
+ headers: {
336
+ 'Authorization': `Bearer ${token}`,
337
+ },
338
+ });
339
+
340
+ if (!response.ok) {
341
+ const error = await response.json().catch(() => ({ error: 'Failed to fetch avatar' }));
342
+
343
+ // If we get a 401, try to refresh and retry once
344
+ if (response.status === 401 && this.onTokenInvalid) {
345
+ console.warn('[PreferencesService] Got 401 on getMyAvatar, attempting refresh...');
346
+ const newToken = await this.onTokenInvalid();
347
+ if (newToken && isValidJwtFormat(newToken)) {
348
+ const retryResponse = await fetch(`${this.brandingUrl}/branding/my-avatar`, {
349
+ headers: {
350
+ 'Authorization': `Bearer ${newToken}`,
351
+ },
352
+ });
353
+
354
+ if (retryResponse.ok) {
355
+ const data = await retryResponse.json();
356
+ if (data.success) return data.data;
357
+ }
358
+ }
359
+ }
360
+
361
+ throw new Error(error.error || error.message || 'Failed to fetch avatar');
362
+ }
363
+
364
+ const data = await response.json();
365
+ if (!data.success) {
366
+ throw new Error(data.error || 'Failed to fetch avatar');
367
+ }
368
+
369
+ return data.data;
370
+ }
371
+
372
+ /**
373
+ * Get any user's avatar
374
+ */
375
+ async getUserAvatar(userId: string): Promise<MyAvatarResponse> {
376
+ const token = await this.getValidToken();
377
+
378
+ const response = await fetch(`${this.brandingUrl}/branding/users/${userId}/avatar`, {
379
+ headers: {
380
+ 'Authorization': `Bearer ${token}`,
381
+ },
382
+ });
383
+
384
+ if (!response.ok) {
385
+ const error = await response.json().catch(() => ({ error: 'Failed to fetch avatar' }));
386
+
387
+ // If we get a 401, try to refresh and retry once
388
+ if (response.status === 401 && this.onTokenInvalid) {
389
+ console.warn('[PreferencesService] Got 401 on getUserAvatar, attempting refresh...');
390
+ const newToken = await this.onTokenInvalid();
391
+ if (newToken && isValidJwtFormat(newToken)) {
392
+ const retryResponse = await fetch(`${this.brandingUrl}/branding/users/${userId}/avatar`, {
393
+ headers: {
394
+ 'Authorization': `Bearer ${newToken}`,
395
+ },
396
+ });
397
+
398
+ if (retryResponse.ok) {
399
+ const data = await retryResponse.json();
400
+ if (data.success) return data.data;
401
+ }
402
+ }
403
+ }
404
+
405
+ throw new Error(error.error || error.message || 'Failed to fetch avatar');
406
+ }
407
+
408
+ const data = await response.json();
409
+ if (!data.success) {
410
+ throw new Error(data.error || 'Failed to fetch avatar');
411
+ }
412
+
413
+ return data.data;
414
+ }
415
+
416
+ /**
417
+ * Complete avatar upload flow (3-step process)
418
+ */
419
+ async uploadAvatar(file: File): Promise<string> {
420
+ // Validate file type
421
+ const validTypes = ['image/png', 'image/jpeg', 'image/jpg', 'image/webp'];
422
+ if (!validTypes.includes(file.type)) {
423
+ throw new Error('Please select a PNG, JPEG, or WebP image');
424
+ }
425
+
426
+ // Validate file size (5MB max)
427
+ const maxSize = 5 * 1024 * 1024;
428
+ if (file.size > maxSize) {
429
+ throw new Error('Image must be less than 5MB');
430
+ }
431
+
432
+ // Step 1: Prepare upload
433
+ const prepareData = await this.prepareAvatarUpload(file.name, file.type);
434
+
435
+ // Step 2: Upload to GCS
436
+ await this.uploadToGCS(prepareData.upload_url, file);
437
+
438
+ // Step 3: Confirm upload
439
+ const confirmData = await this.confirmAvatarUpload(prepareData.storage_path);
440
+
441
+ return confirmData.avatar_url;
442
+ }
443
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * In-memory cache for avatar URLs
3
+ * Shared across all components using the preferences context
4
+ */
5
+
6
+ interface CacheEntry {
7
+ url: string;
8
+ cachedAt: number;
9
+ }
10
+
11
+ // Cache TTL: 1 day (as per API guidance)
12
+ export const AVATAR_CACHE_TTL = 24 * 60 * 60 * 1000;
13
+
14
+ // Global in-memory cache
15
+ const avatarCache = new Map<string, CacheEntry>();
16
+
17
+ /**
18
+ * Get cached avatar URL if valid
19
+ */
20
+ export function getCachedAvatar(userId: string): string | null {
21
+ const cached = avatarCache.get(userId);
22
+ if (cached && Date.now() - cached.cachedAt < AVATAR_CACHE_TTL) {
23
+ return cached.url;
24
+ }
25
+ return null;
26
+ }
27
+
28
+ /**
29
+ * Set avatar URL in cache
30
+ */
31
+ export function setCachedAvatar(userId: string, url: string): void {
32
+ avatarCache.set(userId, {
33
+ url,
34
+ cachedAt: Date.now(),
35
+ });
36
+ }
37
+
38
+ /**
39
+ * Clear cached avatar for a specific user or all users
40
+ */
41
+ export function clearAvatarCache(userId?: string): void {
42
+ if (userId) {
43
+ avatarCache.delete(userId);
44
+ } else {
45
+ avatarCache.clear();
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Check if cache entry exists and is valid
51
+ */
52
+ export function hasCachedAvatar(userId: string): boolean {
53
+ const cached = avatarCache.get(userId);
54
+ return cached !== undefined && Date.now() - cached.cachedAt < AVATAR_CACHE_TTL;
55
+ }
package/src/index.ts ADDED
@@ -0,0 +1,38 @@
1
+ /**
2
+ * @clarityops/preferences
3
+ *
4
+ * Shared user preferences context for InsightForge and InsightFlow platforms.
5
+ * Provides unified avatar, theme, and notification preferences management.
6
+ */
7
+
8
+ // Context and hooks
9
+ export {
10
+ PreferencesProvider,
11
+ usePreferences,
12
+ usePreferencesSafe,
13
+ useAvatar,
14
+ type PreferencesProviderProps,
15
+ } from './PreferencesContext';
16
+
17
+ // Service
18
+ export { PreferencesService } from './PreferencesService';
19
+
20
+ // Types
21
+ export type {
22
+ UserPreferences,
23
+ AvatarData,
24
+ PreferencesConfig,
25
+ PreferencesContextValue,
26
+ PrepareAvatarUploadResponse,
27
+ ConfirmAvatarUploadResponse,
28
+ MyAvatarResponse,
29
+ } from './types';
30
+
31
+ // Cache utilities
32
+ export {
33
+ getCachedAvatar,
34
+ setCachedAvatar,
35
+ clearAvatarCache,
36
+ hasCachedAvatar,
37
+ AVATAR_CACHE_TTL,
38
+ } from './avatar-cache';
package/src/types.ts ADDED
@@ -0,0 +1,94 @@
1
+ /**
2
+ * User preferences types shared across InsightForge and InsightFlow
3
+ */
4
+
5
+ export interface AnalysisCenterFilterPreset {
6
+ id: string;
7
+ name: string;
8
+ pattern: 'one_time' | 'recurring_structured' | 'continuous_monitoring';
9
+ category: string | null;
10
+ depth: 'quick_scan' | 'deep_dive' | null;
11
+ createdAt: string;
12
+ }
13
+
14
+ export interface UserPreferences {
15
+ theme?: 'light' | 'dark' | 'system';
16
+ avatar_url?: string;
17
+ locale?: string;
18
+ notification_settings?: {
19
+ email?: boolean;
20
+ push?: boolean;
21
+ sound?: boolean;
22
+ sound_volume?: number; // 0-100
23
+ };
24
+ sidebar_collapsed?: boolean;
25
+ default_client_id?: string;
26
+ timezone?: string;
27
+ avatar_updated_at?: string;
28
+ avatar_storage_path?: string;
29
+ show_demo_companies?: boolean;
30
+ analysis_center_filter_presets?: AnalysisCenterFilterPreset[];
31
+ /** Show WebSocket debug panel for connection troubleshooting (platform admins only) */
32
+ show_ws_debug_panel?: boolean;
33
+ }
34
+
35
+ export interface AvatarData {
36
+ avatarUrl: string | null;
37
+ hasAvatar: boolean;
38
+ loading: boolean;
39
+ error: string | null;
40
+ refresh: () => Promise<void>;
41
+ handleImageError: () => void;
42
+ }
43
+
44
+ export interface PrepareAvatarUploadResponse {
45
+ upload_id: string;
46
+ user_id: string;
47
+ upload_url: string;
48
+ storage_path: string;
49
+ expires_at: string;
50
+ max_size_bytes: number;
51
+ allowed_types: string[];
52
+ }
53
+
54
+ export interface ConfirmAvatarUploadResponse {
55
+ user_id: string;
56
+ avatar_url: string;
57
+ file_size_bytes: number;
58
+ storage_path: string;
59
+ }
60
+
61
+ export interface MyAvatarResponse {
62
+ user_id: string;
63
+ has_avatar: boolean;
64
+ avatar_url: string | null;
65
+ expires_at?: string;
66
+ cache_max_age_seconds?: number;
67
+ }
68
+
69
+ export interface PreferencesConfig {
70
+ apiBaseUrl: string;
71
+ brandingUrl: string;
72
+ getToken: () => string | null;
73
+ /** Called when token is invalid/expired to attempt refresh */
74
+ onTokenInvalid?: () => Promise<string | null>;
75
+ }
76
+
77
+ export interface PreferencesContextValue {
78
+ // Preferences state
79
+ preferences: UserPreferences;
80
+ loading: boolean;
81
+ error: string | null;
82
+
83
+ // Avatar data
84
+ avatar: AvatarData;
85
+
86
+ // Actions
87
+ updatePreferences: (prefs: Partial<UserPreferences>) => Promise<void>;
88
+ refreshPreferences: () => Promise<void>;
89
+ uploadAvatar: (file: File) => Promise<string>;
90
+ clearAvatarCache: (userId?: string) => void;
91
+
92
+ /** Sync preferences on login and apply theme immediately */
93
+ syncOnLogin: () => Promise<void>;
94
+ }