@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.
- package/README.md +2 -2
- package/dist/templates.d.ts +1 -1
- package/dist/templates.js +86 -2
- package/package.json +1 -1
- package/templates/full/dashboard/src/components/reports/DigestStats.tsx +151 -0
- package/templates/full/dashboard/src/components/reports/HealthTrendsReport.tsx +192 -0
- package/templates/full/dashboard/src/components/usage/AIModelBreakdown.tsx +364 -0
- package/templates/full/dashboard/src/components/usage/unified/Recommendations.tsx +149 -0
- package/templates/full/dashboard/src/lib/cloudflare/alerting.ts +486 -0
- package/templates/full/dashboard/src/lib/cloudflare/graphql.ts +4785 -0
- package/templates/full/dashboard/src/lib/cloudflare/project-registry.ts +451 -0
- package/templates/full/dashboard/src/lib/notifications/api.ts +197 -0
- package/templates/full/dashboard/src/lib/notifications/types.ts.hbs +97 -0
- package/templates/full/dashboard/src/lib/patterns/api.ts +120 -0
- package/templates/full/dashboard/src/lib/patterns/types.ts +127 -0
- package/templates/full/dashboard/src/lib/reports/types.ts +231 -0
- package/templates/full/dashboard/src/lib/search/api.ts +258 -0
- package/templates/full/dashboard/src/lib/search/types.ts.hbs +115 -0
- package/templates/full/dashboard/src/lib/settings/api.ts.hbs +201 -0
- package/templates/full/dashboard/src/lib/settings/types.ts.hbs +104 -0
- package/templates/full/dashboard/src/lib/usage/allowance-config.ts.hbs +547 -0
- package/templates/full/dashboard/src/lib/usage/providers.ts +331 -0
- package/templates/shared/dashboard/src/components/reports/ReportInfoButton.tsx +98 -0
- package/templates/shared/dashboard/src/components/usage/react/DashboardShell.tsx +263 -0
- package/templates/shared/dashboard/src/components/usage/react/StatusBadge.tsx +77 -0
- package/templates/shared/dashboard/src/components/usage/react/UsageChart.tsx +391 -0
- package/templates/shared/dashboard/src/components/usage/react/index.ts.hbs +30 -0
- package/templates/shared/dashboard/src/components/usage/react/types.ts +137 -0
- package/templates/shared/dashboard/src/components/usage/transformers.ts +478 -0
- package/templates/shared/dashboard/src/components/usage/unified/AlertBanner.tsx +172 -0
- package/templates/shared/dashboard/src/components/usage/unified/HeroCardsRow.tsx +757 -0
- package/templates/shared/dashboard/src/components/usage/unified/LiveHeader.tsx +169 -0
- package/templates/shared/dashboard/src/components/usage/unified/ProjectsTable.tsx +448 -0
- package/templates/shared/dashboard/src/components/usage/unified/ResourceBreakdown.tsx +236 -0
- package/templates/shared/dashboard/src/components/usage/unified/Sparkline.tsx +127 -0
- package/templates/shared/dashboard/src/components/usage/unified/UnifiedShell.tsx +893 -0
- package/templates/shared/dashboard/src/components/usage/unified/index.ts.hbs +50 -0
- package/templates/shared/dashboard/src/components/usage/unified/types.ts +416 -0
- package/templates/shared/dashboard/src/lib/cloudflare/analytics.ts +310 -0
- package/templates/shared/dashboard/src/lib/cloudflare/d1.ts +55 -0
- package/templates/shared/dashboard/src/lib/cloudflare/index.ts.hbs +120 -0
- package/templates/shared/dashboard/src/lib/infrastructure/types.ts +116 -0
- package/templates/shared/dashboard/src/lib/usage/fetchWithDedup.ts +101 -0
- package/templates/shared/dashboard/src/lib/usage/index.ts.hbs +12 -0
- package/templates/shared/tests/e2e/usage-api.test.ts +909 -0
- package/templates/shared/tests/helpers/mock-storage.ts +166 -0
- package/templates/shared/tests/integration/kv-cache.test.ts +252 -0
- package/templates/shared/tests/integration/platform-usage.test.ts +956 -0
- package/templates/shared/tests/unit/billing.test.ts +331 -0
- package/templates/shared/tests/unit/cloudflare/graphql.test.ts +217 -0
- package/templates/shared/tests/unit/components/usage-transformers.test.ts +473 -0
- package/templates/shared/tests/unit/control.test.ts +226 -0
- package/templates/shared/tests/unit/cost-calculator.test.ts +141 -0
- package/templates/shared/tests/unit/economics.test.ts +365 -0
- package/templates/shared/tests/unit/telemetry-sampling.test.ts +401 -0
- package/templates/standard/dashboard/src/components/reports/CircuitBreakerReport.tsx +474 -0
- package/templates/standard/dashboard/src/components/reports/CostTrendsReport.tsx +229 -0
- package/templates/standard/dashboard/src/components/reports/ErrorTrendsReport.tsx +244 -0
- package/templates/standard/dashboard/src/components/reports/ProjectHealthTable.tsx +251 -0
- package/templates/standard/dashboard/src/components/reports/WarningDigestsTable.tsx +298 -0
- package/templates/standard/dashboard/src/components/usage/react/UsageTable.tsx +385 -0
- package/templates/standard/dashboard/src/components/usage/unified/CircuitBreakerEvents.tsx +305 -0
- package/templates/standard/dashboard/src/components/usage/unified/FeatureBudgets.tsx +472 -0
- package/templates/standard/dashboard/src/lib/errors/api.ts +84 -0
- package/templates/standard/dashboard/src/lib/errors/types.ts +75 -0
- package/templates/standard/dashboard/src/lib/infrastructure/api.ts +141 -0
- package/templates/standard/dashboard/src/lib/infrastructure/gatus.ts.hbs +112 -0
- package/templates/standard/dashboard/src/lib/services/proxy/index.ts +20 -0
- package/templates/standard/dashboard/src/lib/services/proxy/proxy.ts +244 -0
- package/templates/standard/dashboard/src/lib/services/proxy/types.ts +81 -0
- package/templates/standard/tests/integration/platform-sentinel.test.ts +497 -0
- package/templates/standard/tests/unit/cloudflare/alerting.test.ts +480 -0
- package/templates/standard/tests/unit/error-collector/dedup.test.ts +350 -0
- 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
|
+
}
|