@littlebearapps/platform-admin-sdk 2.0.0 → 2.1.0

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.
Files changed (74) hide show
  1. package/README.md +2 -2
  2. package/dist/templates.d.ts +1 -1
  3. package/dist/templates.js +86 -2
  4. package/package.json +1 -1
  5. package/templates/full/dashboard/src/components/reports/DigestStats.tsx +151 -0
  6. package/templates/full/dashboard/src/components/reports/HealthTrendsReport.tsx +192 -0
  7. package/templates/full/dashboard/src/components/usage/AIModelBreakdown.tsx +364 -0
  8. package/templates/full/dashboard/src/components/usage/unified/Recommendations.tsx +149 -0
  9. package/templates/full/dashboard/src/lib/cloudflare/alerting.ts +486 -0
  10. package/templates/full/dashboard/src/lib/cloudflare/graphql.ts +4785 -0
  11. package/templates/full/dashboard/src/lib/cloudflare/project-registry.ts +451 -0
  12. package/templates/full/dashboard/src/lib/notifications/api.ts +197 -0
  13. package/templates/full/dashboard/src/lib/notifications/types.ts.hbs +97 -0
  14. package/templates/full/dashboard/src/lib/patterns/api.ts +120 -0
  15. package/templates/full/dashboard/src/lib/patterns/types.ts +127 -0
  16. package/templates/full/dashboard/src/lib/reports/types.ts +231 -0
  17. package/templates/full/dashboard/src/lib/search/api.ts +258 -0
  18. package/templates/full/dashboard/src/lib/search/types.ts.hbs +115 -0
  19. package/templates/full/dashboard/src/lib/settings/api.ts.hbs +201 -0
  20. package/templates/full/dashboard/src/lib/settings/types.ts.hbs +104 -0
  21. package/templates/full/dashboard/src/lib/usage/allowance-config.ts.hbs +547 -0
  22. package/templates/full/dashboard/src/lib/usage/providers.ts +331 -0
  23. package/templates/shared/dashboard/src/components/reports/ReportInfoButton.tsx +98 -0
  24. package/templates/shared/dashboard/src/components/usage/react/DashboardShell.tsx +263 -0
  25. package/templates/shared/dashboard/src/components/usage/react/StatusBadge.tsx +77 -0
  26. package/templates/shared/dashboard/src/components/usage/react/UsageChart.tsx +391 -0
  27. package/templates/shared/dashboard/src/components/usage/react/index.ts.hbs +30 -0
  28. package/templates/shared/dashboard/src/components/usage/react/types.ts +137 -0
  29. package/templates/shared/dashboard/src/components/usage/transformers.ts +478 -0
  30. package/templates/shared/dashboard/src/components/usage/unified/AlertBanner.tsx +172 -0
  31. package/templates/shared/dashboard/src/components/usage/unified/HeroCardsRow.tsx +757 -0
  32. package/templates/shared/dashboard/src/components/usage/unified/LiveHeader.tsx +169 -0
  33. package/templates/shared/dashboard/src/components/usage/unified/ProjectsTable.tsx +448 -0
  34. package/templates/shared/dashboard/src/components/usage/unified/ResourceBreakdown.tsx +236 -0
  35. package/templates/shared/dashboard/src/components/usage/unified/Sparkline.tsx +127 -0
  36. package/templates/shared/dashboard/src/components/usage/unified/UnifiedShell.tsx +893 -0
  37. package/templates/shared/dashboard/src/components/usage/unified/index.ts.hbs +50 -0
  38. package/templates/shared/dashboard/src/components/usage/unified/types.ts +416 -0
  39. package/templates/shared/dashboard/src/lib/cloudflare/analytics.ts +310 -0
  40. package/templates/shared/dashboard/src/lib/cloudflare/d1.ts +55 -0
  41. package/templates/shared/dashboard/src/lib/cloudflare/index.ts.hbs +120 -0
  42. package/templates/shared/dashboard/src/lib/infrastructure/types.ts +116 -0
  43. package/templates/shared/dashboard/src/lib/usage/fetchWithDedup.ts +101 -0
  44. package/templates/shared/dashboard/src/lib/usage/index.ts.hbs +12 -0
  45. package/templates/shared/tests/e2e/usage-api.test.ts +909 -0
  46. package/templates/shared/tests/helpers/mock-storage.ts +166 -0
  47. package/templates/shared/tests/integration/kv-cache.test.ts +252 -0
  48. package/templates/shared/tests/integration/platform-usage.test.ts +956 -0
  49. package/templates/shared/tests/unit/billing.test.ts +331 -0
  50. package/templates/shared/tests/unit/cloudflare/graphql.test.ts +217 -0
  51. package/templates/shared/tests/unit/components/usage-transformers.test.ts +473 -0
  52. package/templates/shared/tests/unit/control.test.ts +226 -0
  53. package/templates/shared/tests/unit/cost-calculator.test.ts +141 -0
  54. package/templates/shared/tests/unit/economics.test.ts +365 -0
  55. package/templates/shared/tests/unit/telemetry-sampling.test.ts +401 -0
  56. package/templates/standard/dashboard/src/components/reports/CircuitBreakerReport.tsx +474 -0
  57. package/templates/standard/dashboard/src/components/reports/CostTrendsReport.tsx +229 -0
  58. package/templates/standard/dashboard/src/components/reports/ErrorTrendsReport.tsx +244 -0
  59. package/templates/standard/dashboard/src/components/reports/ProjectHealthTable.tsx +251 -0
  60. package/templates/standard/dashboard/src/components/reports/WarningDigestsTable.tsx +298 -0
  61. package/templates/standard/dashboard/src/components/usage/react/UsageTable.tsx +385 -0
  62. package/templates/standard/dashboard/src/components/usage/unified/CircuitBreakerEvents.tsx +305 -0
  63. package/templates/standard/dashboard/src/components/usage/unified/FeatureBudgets.tsx +472 -0
  64. package/templates/standard/dashboard/src/lib/errors/api.ts +84 -0
  65. package/templates/standard/dashboard/src/lib/errors/types.ts +75 -0
  66. package/templates/standard/dashboard/src/lib/infrastructure/api.ts +141 -0
  67. package/templates/standard/dashboard/src/lib/infrastructure/gatus.ts.hbs +112 -0
  68. package/templates/standard/dashboard/src/lib/services/proxy/index.ts +20 -0
  69. package/templates/standard/dashboard/src/lib/services/proxy/proxy.ts +244 -0
  70. package/templates/standard/dashboard/src/lib/services/proxy/types.ts +81 -0
  71. package/templates/standard/tests/integration/platform-sentinel.test.ts +497 -0
  72. package/templates/standard/tests/unit/cloudflare/alerting.test.ts +480 -0
  73. package/templates/standard/tests/unit/error-collector/dedup.test.ts +350 -0
  74. package/templates/standard/tests/unit/error-collector/github.test.ts +187 -0
@@ -0,0 +1,451 @@
1
+ /**
2
+ * Project Registry - D1-backed resource-to-project mapping
3
+ *
4
+ * Provides project identification for Cloudflare resources using
5
+ * the D1 registry as the source of truth, with pattern matching fallback.
6
+ *
7
+ * Tables:
8
+ * - project_registry: Project metadata (id, name, color, status)
9
+ * - resource_project_mapping: Maps (resource_type, resource_id) → project_id
10
+ * - resource_types: Valid resource types with metadata
11
+ */
12
+
13
+ import type { D1Database } from '@cloudflare/workers-types';
14
+
15
+ // =============================================================================
16
+ // TYPES
17
+ // =============================================================================
18
+
19
+ export interface Project {
20
+ projectId: string;
21
+ displayName: string;
22
+ description: string | null;
23
+ color: string | null;
24
+ icon: string | null;
25
+ owner: string | null;
26
+ repoPath: string | null;
27
+ status: 'active' | 'archived' | 'development';
28
+ /** Primary resource type for utilization tracking (e.g., 'd1', 'workers', 'vectorize') */
29
+ primaryResource: ResourceType | null;
30
+ /** Custom limit for the primary resource (overrides global CF_ALLOWANCES) */
31
+ customLimit: number | null;
32
+ /** Full GitHub repository URL (e.g., 'https://github.com/your-org/your-project') */
33
+ repoUrl: string | null;
34
+ /** GitHub repository identifier (e.g., 'your-org/your-project') */
35
+ githubRepoId: string | null;
36
+ }
37
+
38
+ export interface ResourceMapping {
39
+ resourceType: ResourceType;
40
+ resourceId: string;
41
+ resourceName: string;
42
+ projectId: string;
43
+ environment: 'production' | 'staging' | 'preview' | 'development';
44
+ notes: string | null;
45
+ }
46
+
47
+ export type ResourceType =
48
+ | 'worker'
49
+ | 'd1'
50
+ | 'kv'
51
+ | 'r2'
52
+ | 'vectorize'
53
+ | 'queue'
54
+ | 'workflow'
55
+ | 'ai_gateway'
56
+ | 'workers_ai'
57
+ | 'durable_object'
58
+ | 'pages'
59
+ | 'analytics_engine';
60
+
61
+ // =============================================================================
62
+ // REGISTRY CACHE
63
+ // =============================================================================
64
+
65
+ /**
66
+ * In-memory cache for registry data.
67
+ * Refreshed per-request or when TTL expires.
68
+ */
69
+ interface RegistryCache {
70
+ projects: Map<string, Project>;
71
+ resourcesByName: Map<string, ResourceMapping>;
72
+ resourcesById: Map<string, ResourceMapping>;
73
+ loadedAt: number;
74
+ }
75
+
76
+ let cache: RegistryCache | null = null;
77
+ const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
78
+
79
+ /**
80
+ * Check if cache is valid (exists and not expired)
81
+ */
82
+ function isCacheValid(): boolean {
83
+ return cache !== null && Date.now() - cache.loadedAt < CACHE_TTL_MS;
84
+ }
85
+
86
+ /**
87
+ * Clear the in-memory cache (call after updates)
88
+ */
89
+ export function clearRegistryCache(): void {
90
+ cache = null;
91
+ }
92
+
93
+ // =============================================================================
94
+ // D1 QUERIES
95
+ // =============================================================================
96
+
97
+ /**
98
+ * Load all projects from D1
99
+ */
100
+ async function loadProjects(db: D1Database): Promise<Map<string, Project>> {
101
+ const result = await db
102
+ .prepare(
103
+ `
104
+ SELECT
105
+ project_id, display_name, description, color, icon, owner, repo_path, status,
106
+ primary_resource, custom_limit, repo_url, github_repo_id
107
+ FROM project_registry
108
+ WHERE status != 'archived'
109
+ ORDER BY project_id
110
+ `
111
+ )
112
+ .all<{
113
+ project_id: string;
114
+ display_name: string;
115
+ description: string | null;
116
+ color: string | null;
117
+ icon: string | null;
118
+ owner: string | null;
119
+ repo_path: string | null;
120
+ status: string;
121
+ primary_resource: string | null;
122
+ custom_limit: number | null;
123
+ repo_url: string | null;
124
+ github_repo_id: string | null;
125
+ }>();
126
+
127
+ const projects = new Map<string, Project>();
128
+ for (const row of result.results ?? []) {
129
+ projects.set(row.project_id, {
130
+ projectId: row.project_id,
131
+ displayName: row.display_name,
132
+ description: row.description,
133
+ color: row.color,
134
+ icon: row.icon,
135
+ owner: row.owner,
136
+ repoPath: row.repo_path,
137
+ status: row.status as Project['status'],
138
+ primaryResource: row.primary_resource as ResourceType | null,
139
+ customLimit: row.custom_limit,
140
+ repoUrl: row.repo_url,
141
+ githubRepoId: row.github_repo_id,
142
+ });
143
+ }
144
+
145
+ return projects;
146
+ }
147
+
148
+ /**
149
+ * Load all resource mappings from D1
150
+ */
151
+ async function loadResourceMappings(
152
+ db: D1Database
153
+ ): Promise<{ byName: Map<string, ResourceMapping>; byId: Map<string, ResourceMapping> }> {
154
+ const result = await db
155
+ .prepare(
156
+ `
157
+ SELECT
158
+ resource_type, resource_id, resource_name, project_id, environment, notes
159
+ FROM resource_project_mapping
160
+ ORDER BY resource_type, resource_name
161
+ `
162
+ )
163
+ .all<{
164
+ resource_type: string;
165
+ resource_id: string;
166
+ resource_name: string;
167
+ project_id: string;
168
+ environment: string;
169
+ notes: string | null;
170
+ }>();
171
+
172
+ const byName = new Map<string, ResourceMapping>();
173
+ const byId = new Map<string, ResourceMapping>();
174
+
175
+ for (const row of result.results ?? []) {
176
+ const mapping: ResourceMapping = {
177
+ resourceType: row.resource_type as ResourceType,
178
+ resourceId: row.resource_id,
179
+ resourceName: row.resource_name,
180
+ projectId: row.project_id,
181
+ environment: row.environment as ResourceMapping['environment'],
182
+ notes: row.notes,
183
+ };
184
+
185
+ // Index by both name and ID for flexible lookups
186
+ // Key format: "{type}:{name}" or "{type}:{id}"
187
+ const nameKey = `${row.resource_type}:${row.resource_name.toLowerCase()}`;
188
+ const idKey = `${row.resource_type}:${row.resource_id}`;
189
+
190
+ byName.set(nameKey, mapping);
191
+ byId.set(idKey, mapping);
192
+ }
193
+
194
+ return { byName, byId };
195
+ }
196
+
197
+ /**
198
+ * Load the full registry into cache
199
+ */
200
+ async function loadRegistry(db: D1Database): Promise<RegistryCache> {
201
+ const [projects, resources] = await Promise.all([loadProjects(db), loadResourceMappings(db)]);
202
+
203
+ cache = {
204
+ projects,
205
+ resourcesByName: resources.byName,
206
+ resourcesById: resources.byId,
207
+ loadedAt: Date.now(),
208
+ };
209
+
210
+ console.log(
211
+ `[ProjectRegistry] Loaded ${projects.size} projects, ${resources.byName.size} resource mappings`
212
+ );
213
+
214
+ return cache;
215
+ }
216
+
217
+ /**
218
+ * Get the registry cache, loading from D1 if needed
219
+ */
220
+ async function getRegistry(db: D1Database): Promise<RegistryCache> {
221
+ if (isCacheValid() && cache) {
222
+ return cache;
223
+ }
224
+ return loadRegistry(db);
225
+ }
226
+
227
+ // =============================================================================
228
+ // PUBLIC API
229
+ // =============================================================================
230
+
231
+ /**
232
+ * Get all active projects
233
+ */
234
+ export async function getProjects(db: D1Database): Promise<Project[]> {
235
+ const registry = await getRegistry(db);
236
+ return Array.from(registry.projects.values());
237
+ }
238
+
239
+ /**
240
+ * Get a specific project by ID
241
+ */
242
+ export async function getProject(db: D1Database, projectId: string): Promise<Project | null> {
243
+ const registry = await getRegistry(db);
244
+ return registry.projects.get(projectId) ?? null;
245
+ }
246
+
247
+ /**
248
+ * Identify which project a resource belongs to.
249
+ *
250
+ * Lookup order:
251
+ * 1. D1 registry by resource name (case-insensitive)
252
+ * 2. D1 registry by resource ID
253
+ * 3. Fallback pattern matching (for resources not yet in registry)
254
+ *
255
+ * @param db - D1 database binding
256
+ * @param resourceType - Type of resource (worker, d1, kv, etc.)
257
+ * @param resourceNameOrId - Resource name or Cloudflare ID
258
+ * @returns Project ID or null if not found
259
+ */
260
+ export async function identifyProjectFromRegistry(
261
+ db: D1Database,
262
+ resourceType: ResourceType,
263
+ resourceNameOrId: string
264
+ ): Promise<string | null> {
265
+ const registry = await getRegistry(db);
266
+
267
+ // Try lookup by name first (case-insensitive)
268
+ const nameKey = `${resourceType}:${resourceNameOrId.toLowerCase()}`;
269
+ const byName = registry.resourcesByName.get(nameKey);
270
+ if (byName) {
271
+ return byName.projectId;
272
+ }
273
+
274
+ // Try lookup by ID
275
+ const idKey = `${resourceType}:${resourceNameOrId}`;
276
+ const byId = registry.resourcesById.get(idKey);
277
+ if (byId) {
278
+ return byId.projectId;
279
+ }
280
+
281
+ // Not found in registry
282
+ return null;
283
+ }
284
+
285
+ /**
286
+ * Get all resources for a specific project
287
+ */
288
+ export async function getProjectResources(
289
+ db: D1Database,
290
+ projectId: string
291
+ ): Promise<ResourceMapping[]> {
292
+ const registry = await getRegistry(db);
293
+ const resources: ResourceMapping[] = [];
294
+
295
+ registry.resourcesByName.forEach((mapping) => {
296
+ if (mapping.projectId === projectId) {
297
+ resources.push(mapping);
298
+ }
299
+ });
300
+
301
+ return resources;
302
+ }
303
+
304
+ /**
305
+ * Get resources by type for a project
306
+ */
307
+ export async function getProjectResourcesByType(
308
+ db: D1Database,
309
+ projectId: string,
310
+ resourceType: ResourceType
311
+ ): Promise<ResourceMapping[]> {
312
+ const all = await getProjectResources(db, projectId);
313
+ return all.filter((r) => r.resourceType === resourceType);
314
+ }
315
+
316
+ /**
317
+ * Count resources by project
318
+ */
319
+ export async function getResourceCountsByProject(db: D1Database): Promise<Map<string, number>> {
320
+ const registry = await getRegistry(db);
321
+ const counts = new Map<string, number>();
322
+
323
+ registry.resourcesByName.forEach((mapping) => {
324
+ const current = counts.get(mapping.projectId) ?? 0;
325
+ counts.set(mapping.projectId, current + 1);
326
+ });
327
+
328
+ return counts;
329
+ }
330
+
331
+ /**
332
+ * Get resource count by type for a project
333
+ */
334
+ export async function getResourceCountByType(
335
+ db: D1Database,
336
+ projectId: string
337
+ ): Promise<Record<ResourceType, number>> {
338
+ const resources = await getProjectResources(db, projectId);
339
+ const counts: Record<string, number> = {};
340
+
341
+ for (const r of resources) {
342
+ counts[r.resourceType] = (counts[r.resourceType] ?? 0) + 1;
343
+ }
344
+
345
+ return counts as Record<ResourceType, number>;
346
+ }
347
+
348
+ // =============================================================================
349
+ // REGISTRY UPDATES
350
+ // =============================================================================
351
+
352
+ /**
353
+ * Add or update a resource mapping
354
+ */
355
+ export async function upsertResourceMapping(
356
+ db: D1Database,
357
+ mapping: Omit<ResourceMapping, 'notes'> & { notes?: string | null }
358
+ ): Promise<void> {
359
+ await db
360
+ .prepare(
361
+ `
362
+ INSERT OR REPLACE INTO resource_project_mapping
363
+ (resource_type, resource_id, resource_name, project_id, environment, notes, updated_at)
364
+ VALUES (?, ?, ?, ?, ?, ?, unixepoch())
365
+ `
366
+ )
367
+ .bind(
368
+ mapping.resourceType,
369
+ mapping.resourceId,
370
+ mapping.resourceName,
371
+ mapping.projectId,
372
+ mapping.environment,
373
+ mapping.notes ?? null
374
+ )
375
+ .run();
376
+
377
+ // Clear cache to pick up changes
378
+ clearRegistryCache();
379
+ }
380
+
381
+ /**
382
+ * Remove a resource mapping
383
+ */
384
+ export async function deleteResourceMapping(
385
+ db: D1Database,
386
+ resourceType: ResourceType,
387
+ resourceId: string
388
+ ): Promise<void> {
389
+ await db
390
+ .prepare(
391
+ `
392
+ DELETE FROM resource_project_mapping
393
+ WHERE resource_type = ? AND resource_id = ?
394
+ `
395
+ )
396
+ .bind(resourceType, resourceId)
397
+ .run();
398
+
399
+ clearRegistryCache();
400
+ }
401
+
402
+ /**
403
+ * Add a new project
404
+ */
405
+ export async function createProject(
406
+ db: D1Database,
407
+ project: Omit<Project, 'status'> & { status?: Project['status'] }
408
+ ): Promise<void> {
409
+ await db
410
+ .prepare(
411
+ `
412
+ INSERT INTO project_registry
413
+ (project_id, display_name, description, color, icon, owner, repo_path, status, updated_at)
414
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, unixepoch())
415
+ `
416
+ )
417
+ .bind(
418
+ project.projectId,
419
+ project.displayName,
420
+ project.description,
421
+ project.color,
422
+ project.icon,
423
+ project.owner,
424
+ project.repoPath,
425
+ project.status ?? 'active'
426
+ )
427
+ .run();
428
+
429
+ clearRegistryCache();
430
+ }
431
+
432
+ /**
433
+ * Update project status
434
+ */
435
+ export async function updateProjectStatus(
436
+ db: D1Database,
437
+ projectId: string,
438
+ status: Project['status']
439
+ ): Promise<void> {
440
+ await db
441
+ .prepare(
442
+ `
443
+ UPDATE project_registry SET status = ?, updated_at = unixepoch()
444
+ WHERE project_id = ?
445
+ `
446
+ )
447
+ .bind(status, projectId)
448
+ .run();
449
+
450
+ clearRegistryCache();
451
+ }
@@ -0,0 +1,197 @@
1
+ /**
2
+ * Notification API Client
3
+ *
4
+ * Functions for interacting with the platform-notifications worker.
5
+ * Used by dashboard components to fetch and manage notifications.
6
+ *
7
+ * @module dashboard/lib/notifications/api
8
+ * @created 2026-02-03
9
+ * @task task-303.1
10
+ */
11
+
12
+ import type {
13
+ Notification,
14
+ NotificationListResponse,
15
+ UnreadCountResponse,
16
+ MarkReadResponse,
17
+ NotificationPreferences,
18
+ CreateNotificationRequest,
19
+ NotificationQueryParams,
20
+ } from './types';
21
+
22
+ /** Base URL for notification API (via service binding proxy) */
23
+ const API_BASE = '/api/notifications';
24
+
25
+ /**
26
+ * Fetch notifications with optional filters
27
+ */
28
+ export async function getNotifications(
29
+ params: NotificationQueryParams = {}
30
+ ): Promise<NotificationListResponse> {
31
+ const searchParams = new URLSearchParams();
32
+ if (params.project) searchParams.set('project', params.project);
33
+ if (params.source) searchParams.set('source', params.source);
34
+ if (params.category) searchParams.set('category', params.category);
35
+ if (params.limit) searchParams.set('limit', String(params.limit));
36
+ if (params.offset) searchParams.set('offset', String(params.offset));
37
+
38
+ const url = `${API_BASE}?${searchParams.toString()}`;
39
+ const response = await fetch(url);
40
+
41
+ if (!response.ok) {
42
+ throw new Error(`Failed to fetch notifications: ${response.statusText}`);
43
+ }
44
+
45
+ return response.json();
46
+ }
47
+
48
+ /**
49
+ * Get unread notification count for badge display
50
+ */
51
+ export async function getUnreadCount(): Promise<number> {
52
+ const response = await fetch(`${API_BASE}/unread-count`);
53
+
54
+ if (!response.ok) {
55
+ throw new Error(`Failed to fetch unread count: ${response.statusText}`);
56
+ }
57
+
58
+ const data: UnreadCountResponse = await response.json();
59
+ return data.count;
60
+ }
61
+
62
+ /**
63
+ * Mark a single notification as read
64
+ */
65
+ export async function markAsRead(notificationId: string): Promise<MarkReadResponse> {
66
+ const response = await fetch(`${API_BASE}/${notificationId}/read`, {
67
+ method: 'POST',
68
+ });
69
+
70
+ if (!response.ok) {
71
+ throw new Error(`Failed to mark notification as read: ${response.statusText}`);
72
+ }
73
+
74
+ return response.json();
75
+ }
76
+
77
+ /**
78
+ * Mark all notifications as read
79
+ */
80
+ export async function markAllAsRead(): Promise<MarkReadResponse> {
81
+ const response = await fetch(`${API_BASE}/read-all`, {
82
+ method: 'POST',
83
+ });
84
+
85
+ if (!response.ok) {
86
+ throw new Error(`Failed to mark all notifications as read: ${response.statusText}`);
87
+ }
88
+
89
+ return response.json();
90
+ }
91
+
92
+ /**
93
+ * Create a new notification (internal use)
94
+ */
95
+ export async function createNotification(
96
+ data: CreateNotificationRequest
97
+ ): Promise<{ success: boolean; id: string }> {
98
+ const response = await fetch(API_BASE, {
99
+ method: 'POST',
100
+ headers: { 'Content-Type': 'application/json' },
101
+ body: JSON.stringify(data),
102
+ });
103
+
104
+ if (!response.ok) {
105
+ throw new Error(`Failed to create notification: ${response.statusText}`);
106
+ }
107
+
108
+ return response.json();
109
+ }
110
+
111
+ /**
112
+ * Get user notification preferences
113
+ */
114
+ export async function getPreferences(): Promise<NotificationPreferences> {
115
+ const response = await fetch(`${API_BASE}/preferences`);
116
+
117
+ if (!response.ok) {
118
+ throw new Error(`Failed to fetch preferences: ${response.statusText}`);
119
+ }
120
+
121
+ return response.json();
122
+ }
123
+
124
+ /**
125
+ * Update user notification preferences
126
+ */
127
+ export async function updatePreferences(
128
+ preferences: Partial<NotificationPreferences>
129
+ ): Promise<{ success: boolean; preferences: NotificationPreferences }> {
130
+ const response = await fetch(`${API_BASE}/preferences`, {
131
+ method: 'PUT',
132
+ headers: { 'Content-Type': 'application/json' },
133
+ body: JSON.stringify(preferences),
134
+ });
135
+
136
+ if (!response.ok) {
137
+ throw new Error(`Failed to update preferences: ${response.statusText}`);
138
+ }
139
+
140
+ return response.json();
141
+ }
142
+
143
+ /**
144
+ * Get category icon name (for Lucide icons)
145
+ */
146
+ export function getCategoryIcon(category: Notification['category']): string {
147
+ switch (category) {
148
+ case 'error':
149
+ return 'alert-circle';
150
+ case 'warning':
151
+ return 'alert-triangle';
152
+ case 'success':
153
+ return 'check-circle';
154
+ case 'info':
155
+ default:
156
+ return 'info';
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Get category color class (Tailwind)
162
+ */
163
+ export function getCategoryColor(category: Notification['category']): string {
164
+ switch (category) {
165
+ case 'error':
166
+ return 'text-red-500 dark:text-red-400';
167
+ case 'warning':
168
+ return 'text-amber-500 dark:text-amber-400';
169
+ case 'success':
170
+ return 'text-green-500 dark:text-green-400';
171
+ case 'info':
172
+ default:
173
+ return 'text-blue-500 dark:text-blue-400';
174
+ }
175
+ }
176
+
177
+ /**
178
+ * Format notification timestamp for display
179
+ */
180
+ export function formatTimestamp(timestamp: number): string {
181
+ const date = new Date(timestamp * 1000);
182
+ const now = new Date();
183
+ const diffMs = now.getTime() - date.getTime();
184
+ const diffMins = Math.floor(diffMs / 60000);
185
+ const diffHours = Math.floor(diffMins / 60);
186
+ const diffDays = Math.floor(diffHours / 24);
187
+
188
+ if (diffMins < 1) return 'Just now';
189
+ if (diffMins < 60) return `${diffMins}m ago`;
190
+ if (diffHours < 24) return `${diffHours}h ago`;
191
+ if (diffDays < 7) return `${diffDays}d ago`;
192
+
193
+ return date.toLocaleDateString('en-AU', {
194
+ day: 'numeric',
195
+ month: 'short',
196
+ });
197
+ }