@gridframe/server 1.0.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 ADDED
@@ -0,0 +1,52 @@
1
+ # @gridframe/server
2
+
3
+ Framework-neutral server helpers for API-managed Gridframe Dashboards.
4
+
5
+ ```ts
6
+ import { createDashboardHandlers } from "@gridframe/server";
7
+
8
+ const handlers = createDashboardHandlers({
9
+ repository,
10
+ cardLibrary,
11
+ defaultDashboard: ({ userId }) => dashboardSeedFor(userId),
12
+ resolveCardData: async ({ card }) => loadCardData(card),
13
+ });
14
+ ```
15
+
16
+ The handlers use the standard Fetch `Request`/`Response` APIs, so framework adapters only need to extract route params and pass the request through.
17
+
18
+ ## Next.js route handler
19
+
20
+ ```ts
21
+ export async function POST(
22
+ request: Request,
23
+ context: { params: Promise<{ userId: string }> },
24
+ ) {
25
+ const { userId } = await context.params;
26
+ return handlers.bootstrap(request, { userId });
27
+ }
28
+ ```
29
+
30
+ ## Express route
31
+
32
+ ```ts
33
+ app.post("/api/gridframe/users/:userId/dashboards/bootstrap", async (req, res) => {
34
+ const request = new Request(req.protocol + "://" + req.get("host") + req.originalUrl, {
35
+ method: req.method,
36
+ headers: req.headers as HeadersInit,
37
+ body: JSON.stringify(req.body),
38
+ });
39
+ const response = await handlers.bootstrap(request, { userId: req.params.userId });
40
+ res.status(response.status).set(Object.fromEntries(response.headers)).send(await response.text());
41
+ });
42
+ ```
43
+
44
+ ## TanStack Start or Vite-style server
45
+
46
+ ```ts
47
+ export async function handle({ request, params }) {
48
+ return handlers.bootstrap(request, { userId: params.userId });
49
+ }
50
+ ```
51
+
52
+ Host applications own authorization. The `userId` passed to handlers is an application identity key and must not be trusted without the host app checking access.
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "@gridframe/server",
3
+ "version": "1.0.0",
4
+ "exports": {
5
+ ".": "./src/index.ts"
6
+ },
7
+ "files": [
8
+ "src",
9
+ "README.md"
10
+ ],
11
+ "publishConfig": {
12
+ "access": "public"
13
+ },
14
+ "devDependencies": {
15
+ "eslint": "^9.39.1",
16
+ "typescript": "5.9.2",
17
+ "vitest": "^4.1.10",
18
+ "@gridframe/eslint-config": "0.0.0",
19
+ "@gridframe/typescript-config": "0.0.0"
20
+ },
21
+ "dependencies": {
22
+ "@gridframe/core": "^1.0.0"
23
+ },
24
+ "scripts": {
25
+ "lint": "eslint . --max-warnings 0",
26
+ "test": "vitest run src",
27
+ "check-types": "tsc --noEmit"
28
+ }
29
+ }
@@ -0,0 +1,480 @@
1
+ import { beforeEach, describe, expect, it } from "vitest";
2
+ import type {
3
+ DashboardCardLayout,
4
+ PanelCardDataResponse,
5
+ } from "@gridframe/core";
6
+
7
+ import {
8
+ DashboardInvalidLibraryItemError,
9
+ DashboardNotFoundError,
10
+ DashboardRevisionConflictError,
11
+ createDashboardHandlers,
12
+ type CardLibraryTemplate,
13
+ type DashboardBootstrap,
14
+ type DashboardRepository,
15
+ type DashboardSeed,
16
+ type PersistedDashboard,
17
+ type PersistedDashboardCard,
18
+ } from ".";
19
+
20
+ const templates = [
21
+ {
22
+ key: "revenue",
23
+ name: "Revenue",
24
+ description: "Revenue metric",
25
+ visualization: "metric",
26
+ defaultLayout: { width: 1, height: 2 },
27
+ deeplinkLabel: "View revenue",
28
+ },
29
+ {
30
+ key: "orders",
31
+ name: "Orders",
32
+ visualization: "bar",
33
+ defaultLayout: { width: 3, height: 4 },
34
+ },
35
+ ] satisfies CardLibraryTemplate[];
36
+
37
+ const defaultDashboard = {
38
+ title: "Operations overview",
39
+ description: "Seeded dashboard",
40
+ footer: { text: "Example footer" },
41
+ cards: [
42
+ { libraryItemKey: "revenue", layout: { x: 0, y: 0, width: 1, height: 2 } },
43
+ ],
44
+ } satisfies DashboardSeed;
45
+
46
+ describe("createDashboardHandlers", () => {
47
+ let repository: MemoryDashboardRepository;
48
+ let resolverResult: PanelCardDataResponse;
49
+
50
+ beforeEach(() => {
51
+ repository = new MemoryDashboardRepository();
52
+ resolverResult = {
53
+ status: "success",
54
+ data: {
55
+ visualization: "metric",
56
+ value: 42,
57
+ label: "Revenue",
58
+ },
59
+ };
60
+ });
61
+
62
+ function handlers() {
63
+ return createDashboardHandlers({
64
+ repository,
65
+ cardLibrary: templates,
66
+ defaultDashboard: () => defaultDashboard,
67
+ resolveCardData: () => Promise.resolve(resolverResult),
68
+ });
69
+ }
70
+
71
+ it("bootstraps a default Dashboard when the user has none", async () => {
72
+ const response = await handlers().bootstrap(
73
+ jsonRequest({}),
74
+ { userId: "user-1" },
75
+ );
76
+
77
+ expect(response.status).toBe(200);
78
+ await expect(response.json()).resolves.toMatchObject({
79
+ dashboards: [{ title: "Operations overview", isDefault: true }],
80
+ dashboard: {
81
+ revision: "1",
82
+ config: {
83
+ title: "Operations overview",
84
+ cards: [
85
+ {
86
+ name: "Revenue",
87
+ visualization: "metric",
88
+ query:
89
+ "/api/gridframe/users/user-1/dashboards/dashboard-1/cards/card-1/data",
90
+ deeplink: {
91
+ href:
92
+ "/gridframe/users/user-1/dashboards/dashboard-1/cards/card-1",
93
+ label: "View revenue",
94
+ },
95
+ },
96
+ ],
97
+ },
98
+ },
99
+ });
100
+ });
101
+
102
+ it("returns not found when an explicit Dashboard is missing", async () => {
103
+ await handlers().bootstrap(jsonRequest({}), { userId: "user-1" });
104
+
105
+ const response = await handlers().bootstrap(
106
+ jsonRequest({ dashboardId: "missing" }),
107
+ { userId: "user-1" },
108
+ );
109
+
110
+ expect(response.status).toBe(404);
111
+ await expect(response.json()).resolves.toEqual({
112
+ error: { code: "DASHBOARD_NOT_FOUND", message: "Dashboard not found" },
113
+ });
114
+ });
115
+
116
+ it("persists a complete valid layout", async () => {
117
+ const dashboard = await seedDashboard();
118
+
119
+ const response = await handlers().updateLayout(
120
+ jsonRequest({
121
+ revision: String(dashboard.revision),
122
+ cards: [{ id: "card-1", x: 1, y: 0, width: 1, height: 2 }],
123
+ }),
124
+ { userId: "user-1", dashboardId: dashboard.id },
125
+ );
126
+
127
+ expect(response.status).toBe(200);
128
+ await expect(response.json()).resolves.toMatchObject({
129
+ revision: "2",
130
+ config: { cards: [{ id: "card-1", layout: { x: 1 } }] },
131
+ });
132
+ });
133
+
134
+ it("rejects layouts that do not contain exactly the Dashboard Cards", async () => {
135
+ const dashboard = await seedDashboard();
136
+
137
+ const response = await handlers().updateLayout(
138
+ jsonRequest({ revision: String(dashboard.revision), cards: [] }),
139
+ { userId: "user-1", dashboardId: dashboard.id },
140
+ );
141
+
142
+ expect(response.status).toBe(400);
143
+ await expect(response.json()).resolves.toEqual({
144
+ error: {
145
+ code: "INVALID_REQUEST",
146
+ message: "Invalid Dashboard layout",
147
+ },
148
+ });
149
+ });
150
+
151
+ it("returns a revision conflict when a mutation uses a stale revision", async () => {
152
+ const dashboard = await seedDashboard();
153
+
154
+ const response = await handlers().updateCard(
155
+ jsonRequest({ revision: "99", name: "Updated" }),
156
+ { userId: "user-1", dashboardId: dashboard.id, cardId: "card-1" },
157
+ );
158
+
159
+ expect(response.status).toBe(409);
160
+ await expect(response.json()).resolves.toEqual({
161
+ error: {
162
+ code: "REVISION_CONFLICT",
163
+ message: "Dashboard was changed by another request",
164
+ },
165
+ });
166
+ });
167
+
168
+ it("lists Card library templates with installed state", async () => {
169
+ const dashboard = await seedDashboard();
170
+
171
+ const response = await handlers().listCardLibrary(new Request("http://x"), {
172
+ userId: "user-1",
173
+ dashboardId: dashboard.id,
174
+ });
175
+
176
+ expect(response.status).toBe(200);
177
+ await expect(response.json()).resolves.toEqual({
178
+ items: [
179
+ {
180
+ key: "revenue",
181
+ name: "Revenue",
182
+ description: "Revenue metric",
183
+ visualization: "metric",
184
+ defaultLayout: { width: 1, height: 2 },
185
+ addedCardId: "card-1",
186
+ },
187
+ {
188
+ key: "orders",
189
+ name: "Orders",
190
+ visualization: "bar",
191
+ defaultLayout: { width: 3, height: 4 },
192
+ },
193
+ ],
194
+ });
195
+ });
196
+
197
+ it("adds a Card library item at the first available layout", async () => {
198
+ const dashboard = await seedDashboard();
199
+
200
+ const response = await handlers().addCard(
201
+ jsonRequest({
202
+ revision: String(dashboard.revision),
203
+ libraryItemKey: "orders",
204
+ }),
205
+ { userId: "user-1", dashboardId: dashboard.id },
206
+ );
207
+
208
+ expect(response.status).toBe(200);
209
+ await expect(response.json()).resolves.toMatchObject({
210
+ dashboard: {
211
+ revision: "2",
212
+ config: {
213
+ cards: [
214
+ { id: "card-1" },
215
+ { id: "card-2", layout: { x: 1, y: 0, width: 3, height: 4 } },
216
+ ],
217
+ },
218
+ },
219
+ cardLibrary: {
220
+ items: [
221
+ { key: "revenue", addedCardId: "card-1" },
222
+ { key: "orders", addedCardId: "card-2" },
223
+ ],
224
+ },
225
+ });
226
+ });
227
+
228
+ it("rejects duplicate Card library items", async () => {
229
+ const dashboard = await seedDashboard();
230
+
231
+ const response = await handlers().addCard(
232
+ jsonRequest({
233
+ revision: String(dashboard.revision),
234
+ libraryItemKey: "revenue",
235
+ }),
236
+ { userId: "user-1", dashboardId: dashboard.id },
237
+ );
238
+
239
+ expect(response.status).toBe(409);
240
+ await expect(response.json()).resolves.toEqual({
241
+ error: {
242
+ code: "CARD_ALREADY_ADDED",
243
+ message: "Card is already on this Dashboard",
244
+ },
245
+ });
246
+ });
247
+
248
+ it("removes the final Card and returns an empty Dashboard", async () => {
249
+ const dashboard = await seedDashboard();
250
+
251
+ const response = await handlers().removeCard(
252
+ jsonRequest({ revision: String(dashboard.revision) }),
253
+ { userId: "user-1", dashboardId: dashboard.id, cardId: "card-1" },
254
+ );
255
+
256
+ expect(response.status).toBe(200);
257
+ await expect(response.json()).resolves.toMatchObject({
258
+ dashboard: { revision: "2", config: { cards: [] } },
259
+ cardLibrary: {
260
+ items: [{ key: "revenue" }, { key: "orders" }],
261
+ },
262
+ });
263
+ });
264
+
265
+ it("returns validated Card data from the consumer resolver", async () => {
266
+ const dashboard = await seedDashboard();
267
+
268
+ const response = await handlers().fetchCardData(
269
+ new Request("http://example.test/data"),
270
+ { userId: "user-1", dashboardId: dashboard.id, cardId: "card-1" },
271
+ );
272
+
273
+ expect(response.status).toBe(200);
274
+ await expect(response.json()).resolves.toEqual(resolverResult);
275
+ });
276
+
277
+ it("rejects invalid Card data resolver output", async () => {
278
+ const dashboard = await seedDashboard();
279
+ resolverResult = { status: "success", data: undefined } as never;
280
+
281
+ const response = await handlers().fetchCardData(
282
+ new Request("http://example.test/data"),
283
+ { userId: "user-1", dashboardId: dashboard.id, cardId: "card-1" },
284
+ );
285
+
286
+ expect(response.status).toBe(502);
287
+ await expect(response.json()).resolves.toEqual({
288
+ error: {
289
+ code: "CARD_QUERY_FAILED",
290
+ message: "Card data could not be loaded",
291
+ },
292
+ });
293
+ });
294
+
295
+ async function seedDashboard() {
296
+ const response = await handlers().bootstrap(
297
+ jsonRequest({}),
298
+ { userId: "user-1" },
299
+ );
300
+ expect(response.status).toBe(200);
301
+ return repository.loadDashboard("user-1", "dashboard-1");
302
+ }
303
+ });
304
+
305
+ function jsonRequest(body: unknown) {
306
+ return new Request("http://example.test", {
307
+ method: "POST",
308
+ headers: { "Content-Type": "application/json" },
309
+ body: JSON.stringify(body),
310
+ });
311
+ }
312
+
313
+ class MemoryDashboardRepository implements DashboardRepository {
314
+ private readonly dashboards = new Map<string, PersistedDashboard>();
315
+ private cardSequence = 1;
316
+ private dashboardSequence = 1;
317
+
318
+ async bootstrap(
319
+ ownerUserId: string,
320
+ dashboardId: string | undefined,
321
+ seed: DashboardSeed,
322
+ cardLibrary: readonly CardLibraryTemplate[],
323
+ ): Promise<DashboardBootstrap> {
324
+ const owned = this.listOwnedDashboards(ownerUserId);
325
+ let dashboard = dashboardId
326
+ ? this.dashboards.get(dashboardId)
327
+ : owned.find((item) => item.isDefault);
328
+
329
+ if (!dashboard && !dashboardId) {
330
+ dashboard = this.createSeededDashboard(ownerUserId, seed, cardLibrary);
331
+ }
332
+
333
+ if (!dashboard || dashboard.ownerUserId !== ownerUserId) {
334
+ throw new DashboardNotFoundError();
335
+ }
336
+
337
+ return {
338
+ dashboards: this.listOwnedDashboards(ownerUserId).map((item) => ({
339
+ id: item.id,
340
+ title: item.title,
341
+ isDefault: item.isDefault,
342
+ })),
343
+ dashboard,
344
+ };
345
+ }
346
+
347
+ async loadDashboard(ownerUserId: string, dashboardId: string) {
348
+ const dashboard = this.dashboards.get(dashboardId);
349
+ if (!dashboard || dashboard.ownerUserId !== ownerUserId) {
350
+ throw new DashboardNotFoundError();
351
+ }
352
+ return dashboard;
353
+ }
354
+
355
+ async updateLayout(
356
+ ownerUserId: string,
357
+ dashboardId: string,
358
+ revision: number,
359
+ cards: Array<{ id: string } & DashboardCardLayout>,
360
+ ) {
361
+ const dashboard = await this.loadDashboard(ownerUserId, dashboardId);
362
+ this.assertRevision(dashboard, revision);
363
+ dashboard.revision += 1;
364
+ dashboard.cards = dashboard.cards.map((card) => ({
365
+ ...card,
366
+ layout: cards.find((item) => item.id === card.id) ?? card.layout,
367
+ }));
368
+ return dashboard;
369
+ }
370
+
371
+ async updateCardName(
372
+ ownerUserId: string,
373
+ dashboardId: string,
374
+ cardId: string,
375
+ revision: number,
376
+ name: string,
377
+ ) {
378
+ const dashboard = await this.loadDashboard(ownerUserId, dashboardId);
379
+ this.assertRevision(dashboard, revision);
380
+ const card = dashboard.cards.find((item) => item.id === cardId);
381
+ if (!card) throw new DashboardNotFoundError();
382
+ card.name = name;
383
+ dashboard.revision += 1;
384
+ return dashboard;
385
+ }
386
+
387
+ async addCard(
388
+ ownerUserId: string,
389
+ dashboardId: string,
390
+ revision: number,
391
+ card: Omit<PersistedDashboardCard, "id" | "dashboardId" | "sortOrder">,
392
+ ) {
393
+ const dashboard = await this.loadDashboard(ownerUserId, dashboardId);
394
+ this.assertRevision(dashboard, revision);
395
+ const created = {
396
+ ...card,
397
+ id: `card-${this.cardSequence++}`,
398
+ dashboardId,
399
+ sortOrder: dashboard.cards.length,
400
+ };
401
+ dashboard.cards.push(created);
402
+ dashboard.revision += 1;
403
+ return dashboard;
404
+ }
405
+
406
+ async removeCard(
407
+ ownerUserId: string,
408
+ dashboardId: string,
409
+ cardId: string,
410
+ revision: number,
411
+ ) {
412
+ const dashboard = await this.loadDashboard(ownerUserId, dashboardId);
413
+ this.assertRevision(dashboard, revision);
414
+ if (!dashboard.cards.some((card) => card.id === cardId)) {
415
+ throw new DashboardNotFoundError();
416
+ }
417
+ dashboard.cards = dashboard.cards.filter((card) => card.id !== cardId);
418
+ dashboard.revision += 1;
419
+ return dashboard;
420
+ }
421
+
422
+ async findOwnedCard(
423
+ ownerUserId: string,
424
+ dashboardId: string,
425
+ cardId: string,
426
+ ) {
427
+ return (await this.loadDashboard(ownerUserId, dashboardId)).cards.find(
428
+ (card) => card.id === cardId,
429
+ );
430
+ }
431
+
432
+ private createSeededDashboard(
433
+ ownerUserId: string,
434
+ seed: DashboardSeed,
435
+ cardLibrary: readonly CardLibraryTemplate[],
436
+ ) {
437
+ const dashboardId = `dashboard-${this.dashboardSequence++}`;
438
+ const dashboard: PersistedDashboard = {
439
+ id: dashboardId,
440
+ ownerUserId,
441
+ title: seed.title,
442
+ description: seed.description,
443
+ footer: seed.footer,
444
+ isDefault: true,
445
+ revision: 1,
446
+ cards: seed.cards.map((seedCard, index) => {
447
+ const template = cardLibrary.find(
448
+ (item) => item.key === seedCard.libraryItemKey,
449
+ );
450
+ if (!template) throw new DashboardInvalidLibraryItemError();
451
+ return {
452
+ id: `card-${this.cardSequence++}`,
453
+ dashboardId,
454
+ libraryItemKey: template.key,
455
+ name: template.name,
456
+ visualization: template.visualization,
457
+ deeplink: template.deeplinkLabel
458
+ ? { label: template.deeplinkLabel }
459
+ : undefined,
460
+ layout: seedCard.layout ?? { x: 0, y: index * 4, ...template.defaultLayout },
461
+ sortOrder: index,
462
+ };
463
+ }),
464
+ };
465
+ this.dashboards.set(dashboard.id, dashboard);
466
+ return dashboard;
467
+ }
468
+
469
+ private listOwnedDashboards(ownerUserId: string) {
470
+ return [...this.dashboards.values()].filter(
471
+ (dashboard) => dashboard.ownerUserId === ownerUserId,
472
+ );
473
+ }
474
+
475
+ private assertRevision(dashboard: PersistedDashboard, revision: number) {
476
+ if (dashboard.revision !== revision) {
477
+ throw new DashboardRevisionConflictError();
478
+ }
479
+ }
480
+ }
package/src/index.ts ADDED
@@ -0,0 +1,710 @@
1
+ import {
2
+ AddDashboardCardRequestSchema,
3
+ CardLibraryResponseSchema,
4
+ DashboardApiErrorSchema,
5
+ DashboardBootstrapRequestSchema,
6
+ DashboardDocumentSchema,
7
+ DASHBOARD_GRID_COLUMNS,
8
+ PanelCardDataResponseSchema,
9
+ RemoveDashboardCardRequestSchema,
10
+ UpdateDashboardCardRequestSchema,
11
+ UpdateDashboardLayoutRequestSchema,
12
+ type CardDeeplinkConfig,
13
+ type CardLibraryItem,
14
+ type DashboardApiError,
15
+ type DashboardBootstrapResponse,
16
+ type DashboardCardLayout,
17
+ type DashboardFooterConfig,
18
+ type DashboardLayoutItem,
19
+ type DashboardSummary,
20
+ type PanelCardDataResponse,
21
+ type VisualizationType,
22
+ } from "@gridframe/core";
23
+ import { validateDashboardLayout } from "@gridframe/core";
24
+
25
+ type MaybePromise<T> = T | Promise<T>;
26
+
27
+ type CardLibraryTemplate = {
28
+ key: string;
29
+ name: string;
30
+ description?: string;
31
+ visualization: VisualizationType;
32
+ defaultLayout: {
33
+ width: number;
34
+ height: number;
35
+ };
36
+ deeplinkLabel?: string;
37
+ };
38
+
39
+ type DashboardSeedCard = {
40
+ libraryItemKey: string;
41
+ layout?: DashboardCardLayout;
42
+ };
43
+
44
+ type DashboardSeed = {
45
+ title: string;
46
+ description?: string;
47
+ footer?: DashboardFooterConfig;
48
+ cards: DashboardSeedCard[];
49
+ };
50
+
51
+ type PersistedDashboardCard = {
52
+ id: string;
53
+ dashboardId: string;
54
+ libraryItemKey?: string;
55
+ name: string;
56
+ visualization: VisualizationType;
57
+ deeplink?: Omit<CardDeeplinkConfig, "href">;
58
+ layout: DashboardCardLayout;
59
+ sortOrder: number;
60
+ };
61
+
62
+ type PersistedDashboard = {
63
+ id: string;
64
+ ownerUserId: string;
65
+ title: string;
66
+ description?: string;
67
+ footer?: DashboardFooterConfig;
68
+ isDefault: boolean;
69
+ revision: number;
70
+ cards: PersistedDashboardCard[];
71
+ };
72
+
73
+ type DashboardBootstrap = {
74
+ dashboards: DashboardSummary[];
75
+ dashboard: PersistedDashboard;
76
+ };
77
+
78
+ type DashboardCardCreate = Omit<
79
+ PersistedDashboardCard,
80
+ "id" | "dashboardId" | "sortOrder"
81
+ >;
82
+
83
+ interface DashboardRepository {
84
+ bootstrap(
85
+ ownerUserId: string,
86
+ dashboardId: string | undefined,
87
+ seed: DashboardSeed,
88
+ cardLibrary: readonly CardLibraryTemplate[],
89
+ ): MaybePromise<DashboardBootstrap>;
90
+ loadDashboard(
91
+ ownerUserId: string,
92
+ dashboardId: string,
93
+ ): MaybePromise<PersistedDashboard>;
94
+ updateLayout(
95
+ ownerUserId: string,
96
+ dashboardId: string,
97
+ revision: number,
98
+ cards: DashboardLayoutItem[],
99
+ ): MaybePromise<PersistedDashboard>;
100
+ updateCardName(
101
+ ownerUserId: string,
102
+ dashboardId: string,
103
+ cardId: string,
104
+ revision: number,
105
+ name: string,
106
+ ): MaybePromise<PersistedDashboard>;
107
+ addCard(
108
+ ownerUserId: string,
109
+ dashboardId: string,
110
+ revision: number,
111
+ card: DashboardCardCreate,
112
+ ): MaybePromise<PersistedDashboard>;
113
+ removeCard(
114
+ ownerUserId: string,
115
+ dashboardId: string,
116
+ cardId: string,
117
+ revision: number,
118
+ ): MaybePromise<PersistedDashboard>;
119
+ findOwnedCard(
120
+ ownerUserId: string,
121
+ dashboardId: string,
122
+ cardId: string,
123
+ ): MaybePromise<PersistedDashboardCard | undefined>;
124
+ }
125
+
126
+ type DashboardHandlerOptions = {
127
+ repository: DashboardRepository;
128
+ cardLibrary:
129
+ | readonly CardLibraryTemplate[]
130
+ | ((context: DashboardContext) => MaybePromise<readonly CardLibraryTemplate[]>);
131
+ defaultDashboard: (context: DashboardContext) => MaybePromise<DashboardSeed>;
132
+ resolveCardData: (
133
+ input: CardDataResolverInput,
134
+ ) => MaybePromise<PanelCardDataResponse>;
135
+ urls?: DashboardUrlOptions;
136
+ };
137
+
138
+ type DashboardUrlOptions = {
139
+ apiBasePath?: string;
140
+ dashboardBasePath?: string;
141
+ };
142
+
143
+ type DashboardContext = {
144
+ userId: string;
145
+ };
146
+
147
+ type DashboardIdentity = DashboardContext & {
148
+ dashboardId: string;
149
+ };
150
+
151
+ type CardIdentity = DashboardIdentity & {
152
+ cardId: string;
153
+ };
154
+
155
+ type CardDataResolverInput = CardIdentity & {
156
+ card: PersistedDashboardCard;
157
+ request: Request;
158
+ };
159
+
160
+ class DashboardNotFoundError extends Error {
161
+ constructor() {
162
+ super("Dashboard not found");
163
+ this.name = "DashboardNotFoundError";
164
+ }
165
+ }
166
+
167
+ class DashboardRevisionConflictError extends Error {
168
+ constructor() {
169
+ super("Dashboard revision conflict");
170
+ this.name = "DashboardRevisionConflictError";
171
+ }
172
+ }
173
+
174
+ class DashboardInvalidLayoutError extends Error {
175
+ constructor(readonly errors: string[]) {
176
+ super(errors.join("; "));
177
+ this.name = "DashboardInvalidLayoutError";
178
+ }
179
+ }
180
+
181
+ class DashboardCardAlreadyAddedError extends Error {
182
+ constructor() {
183
+ super("Card is already on this Dashboard");
184
+ this.name = "DashboardCardAlreadyAddedError";
185
+ }
186
+ }
187
+
188
+ class DashboardInvalidLibraryItemError extends Error {
189
+ constructor() {
190
+ super("Unknown Card library item");
191
+ this.name = "DashboardInvalidLibraryItemError";
192
+ }
193
+ }
194
+
195
+ function createDashboardHandlers(options: DashboardHandlerOptions) {
196
+ const urls = {
197
+ apiBasePath: options.urls?.apiBasePath ?? "/api/gridframe",
198
+ dashboardBasePath: options.urls?.dashboardBasePath ?? "/gridframe",
199
+ };
200
+
201
+ return {
202
+ bootstrap: async (request: Request, context: DashboardContext) => {
203
+ const parsedRequest = DashboardBootstrapRequestSchema.safeParse(
204
+ await readJson(request),
205
+ );
206
+
207
+ if (!parsedRequest.success || !isIdentitySegment(context.userId)) {
208
+ return errorResponse(
209
+ 400,
210
+ "INVALID_REQUEST",
211
+ "Invalid bootstrap request",
212
+ );
213
+ }
214
+
215
+ try {
216
+ const [seed, cardLibrary] = await Promise.all([
217
+ options.defaultDashboard(context),
218
+ resolveCardLibrary(options.cardLibrary, context),
219
+ ]);
220
+ const result = await options.repository.bootstrap(
221
+ context.userId,
222
+ parsedRequest.data.dashboardId,
223
+ seed,
224
+ cardLibrary,
225
+ );
226
+
227
+ return Response.json(serializeDashboardBootstrap(result, urls));
228
+ } catch (error) {
229
+ if (error instanceof DashboardNotFoundError) {
230
+ return errorResponse(
231
+ 404,
232
+ "DASHBOARD_NOT_FOUND",
233
+ "Dashboard not found",
234
+ );
235
+ }
236
+ if (error instanceof DashboardInvalidLibraryItemError) {
237
+ return errorResponse(
238
+ 400,
239
+ "INVALID_REQUEST",
240
+ "Unknown Card library item",
241
+ );
242
+ }
243
+
244
+ return errorResponse(
245
+ 500,
246
+ "DASHBOARD_LOAD_FAILED",
247
+ "Dashboard could not be loaded",
248
+ );
249
+ }
250
+ },
251
+
252
+ updateLayout: async (request: Request, identity: DashboardIdentity) => {
253
+ const parsed = UpdateDashboardLayoutRequestSchema.safeParse(
254
+ await readJson(request),
255
+ );
256
+ const revision = parsed.success
257
+ ? parseRevision(parsed.data.revision)
258
+ : undefined;
259
+
260
+ if (!parsed.success || revision === undefined || !isDashboard(identity)) {
261
+ return errorResponse(
262
+ 400,
263
+ "INVALID_REQUEST",
264
+ "Invalid Dashboard layout request",
265
+ );
266
+ }
267
+
268
+ try {
269
+ const dashboard = await options.repository.loadDashboard(
270
+ identity.userId,
271
+ identity.dashboardId,
272
+ );
273
+ const validation = validateDashboardLayout(
274
+ parsed.data.cards,
275
+ dashboard.cards.map((card) => card.id),
276
+ );
277
+
278
+ if (!validation.valid) {
279
+ throw new DashboardInvalidLayoutError(validation.errors);
280
+ }
281
+
282
+ const updated = await options.repository.updateLayout(
283
+ identity.userId,
284
+ identity.dashboardId,
285
+ revision,
286
+ parsed.data.cards,
287
+ );
288
+
289
+ return Response.json(serializeDashboardDocument(updated, urls));
290
+ } catch (error) {
291
+ return mutationError(error, "Invalid Dashboard layout");
292
+ }
293
+ },
294
+
295
+ updateCard: async (request: Request, identity: CardIdentity) => {
296
+ const parsed = UpdateDashboardCardRequestSchema.safeParse(
297
+ await readJson(request),
298
+ );
299
+ const revision = parsed.success
300
+ ? parseRevision(parsed.data.revision)
301
+ : undefined;
302
+ const name = parsed.success ? parsed.data.name.trim() : "";
303
+
304
+ if (
305
+ !parsed.success ||
306
+ revision === undefined ||
307
+ !name ||
308
+ !isCard(identity)
309
+ ) {
310
+ return errorResponse(
311
+ 400,
312
+ "INVALID_REQUEST",
313
+ "Invalid Card update request",
314
+ );
315
+ }
316
+
317
+ try {
318
+ const updated = await options.repository.updateCardName(
319
+ identity.userId,
320
+ identity.dashboardId,
321
+ identity.cardId,
322
+ revision,
323
+ name,
324
+ );
325
+
326
+ return Response.json(serializeDashboardDocument(updated, urls));
327
+ } catch (error) {
328
+ return mutationError(error, "Invalid Card update request");
329
+ }
330
+ },
331
+
332
+ listCardLibrary: async (_request: Request, identity: DashboardIdentity) => {
333
+ if (!isDashboard(identity)) {
334
+ return errorResponse(
335
+ 400,
336
+ "INVALID_REQUEST",
337
+ "Invalid Card library request",
338
+ );
339
+ }
340
+
341
+ try {
342
+ const [dashboard, cardLibrary] = await Promise.all([
343
+ options.repository.loadDashboard(identity.userId, identity.dashboardId),
344
+ resolveCardLibrary(options.cardLibrary, identity),
345
+ ]);
346
+
347
+ return Response.json(buildCardLibraryResponse(dashboard, cardLibrary));
348
+ } catch (error) {
349
+ return libraryError(error);
350
+ }
351
+ },
352
+
353
+ addCard: async (request: Request, identity: DashboardIdentity) => {
354
+ const parsed = AddDashboardCardRequestSchema.safeParse(
355
+ await readJson(request),
356
+ );
357
+ const revision = parsed.success
358
+ ? parseRevision(parsed.data.revision)
359
+ : undefined;
360
+
361
+ if (!parsed.success || revision === undefined || !isDashboard(identity)) {
362
+ return errorResponse(400, "INVALID_REQUEST", "Invalid Card add request");
363
+ }
364
+
365
+ try {
366
+ const [dashboard, cardLibrary] = await Promise.all([
367
+ options.repository.loadDashboard(identity.userId, identity.dashboardId),
368
+ resolveCardLibrary(options.cardLibrary, identity),
369
+ ]);
370
+ const template = cardLibrary.find(
371
+ (item) => item.key === parsed.data.libraryItemKey,
372
+ );
373
+
374
+ if (!template) {
375
+ throw new DashboardInvalidLibraryItemError();
376
+ }
377
+ if (
378
+ dashboard.cards.some(
379
+ (card) => card.libraryItemKey === parsed.data.libraryItemKey,
380
+ )
381
+ ) {
382
+ throw new DashboardCardAlreadyAddedError();
383
+ }
384
+
385
+ const updated = await options.repository.addCard(
386
+ identity.userId,
387
+ identity.dashboardId,
388
+ revision,
389
+ {
390
+ libraryItemKey: template.key,
391
+ name: template.name,
392
+ visualization: template.visualization,
393
+ deeplink: template.deeplinkLabel
394
+ ? { label: template.deeplinkLabel }
395
+ : undefined,
396
+ layout: firstAvailableLayout(dashboard.cards, template.defaultLayout),
397
+ },
398
+ );
399
+
400
+ return Response.json({
401
+ dashboard: serializeDashboardDocument(updated, urls),
402
+ cardLibrary: buildCardLibraryResponse(updated, cardLibrary),
403
+ });
404
+ } catch (error) {
405
+ return libraryError(error);
406
+ }
407
+ },
408
+
409
+ removeCard: async (request: Request, identity: CardIdentity) => {
410
+ const parsed = RemoveDashboardCardRequestSchema.safeParse(
411
+ await readJson(request),
412
+ );
413
+ const revision = parsed.success
414
+ ? parseRevision(parsed.data.revision)
415
+ : undefined;
416
+
417
+ if (!parsed.success || revision === undefined || !isCard(identity)) {
418
+ return errorResponse(
419
+ 400,
420
+ "INVALID_REQUEST",
421
+ "Invalid Card remove request",
422
+ );
423
+ }
424
+
425
+ try {
426
+ const cardLibrary = await resolveCardLibrary(
427
+ options.cardLibrary,
428
+ identity,
429
+ );
430
+ const updated = await options.repository.removeCard(
431
+ identity.userId,
432
+ identity.dashboardId,
433
+ identity.cardId,
434
+ revision,
435
+ );
436
+
437
+ return Response.json({
438
+ dashboard: serializeDashboardDocument(updated, urls),
439
+ cardLibrary: buildCardLibraryResponse(updated, cardLibrary),
440
+ });
441
+ } catch (error) {
442
+ return libraryError(error);
443
+ }
444
+ },
445
+
446
+ fetchCardData: async (request: Request, identity: CardIdentity) => {
447
+ if (!isCard(identity)) {
448
+ return errorResponse(400, "INVALID_REQUEST", "Invalid Card data request");
449
+ }
450
+
451
+ try {
452
+ const card = await options.repository.findOwnedCard(
453
+ identity.userId,
454
+ identity.dashboardId,
455
+ identity.cardId,
456
+ );
457
+
458
+ if (!card) {
459
+ return errorResponse(
460
+ 404,
461
+ "DASHBOARD_CARD_NOT_FOUND",
462
+ "Dashboard Card not found",
463
+ );
464
+ }
465
+
466
+ const result = await options.resolveCardData({
467
+ ...identity,
468
+ card,
469
+ request,
470
+ });
471
+ const parsed = PanelCardDataResponseSchema.safeParse(result);
472
+
473
+ if (!parsed.success) {
474
+ return cardQueryFailed();
475
+ }
476
+
477
+ return Response.json(parsed.data);
478
+ } catch {
479
+ return cardQueryFailed();
480
+ }
481
+ },
482
+ };
483
+ }
484
+
485
+ function serializeDashboardBootstrap(
486
+ result: DashboardBootstrap,
487
+ urls: Required<DashboardUrlOptions>,
488
+ ): DashboardBootstrapResponse {
489
+ return {
490
+ dashboards: result.dashboards,
491
+ dashboard: serializeDashboardDocument(result.dashboard, urls),
492
+ };
493
+ }
494
+
495
+ function serializeDashboardDocument(
496
+ dashboard: PersistedDashboard,
497
+ urls: Required<DashboardUrlOptions>,
498
+ ) {
499
+ const ownerId = encodeURIComponent(dashboard.ownerUserId);
500
+ const dashboardId = encodeURIComponent(dashboard.id);
501
+
502
+ return DashboardDocumentSchema.parse({
503
+ id: dashboard.id,
504
+ revision: String(dashboard.revision),
505
+ config: {
506
+ title: dashboard.title,
507
+ description: dashboard.description,
508
+ footer: dashboard.footer,
509
+ cards: dashboard.cards.map((card) => {
510
+ const cardId = encodeURIComponent(card.id);
511
+
512
+ return {
513
+ id: card.id,
514
+ name: card.name,
515
+ visualization: card.visualization,
516
+ query:
517
+ `${urls.apiBasePath}/users/${ownerId}` +
518
+ `/dashboards/${dashboardId}/cards/${cardId}/data`,
519
+ deeplink: card.deeplink
520
+ ? {
521
+ href:
522
+ `${urls.dashboardBasePath}/users/${ownerId}` +
523
+ `/dashboards/${dashboardId}/cards/${cardId}`,
524
+ label: card.deeplink.label,
525
+ }
526
+ : undefined,
527
+ layout: card.layout,
528
+ };
529
+ }),
530
+ },
531
+ });
532
+ }
533
+
534
+ function buildCardLibraryResponse(
535
+ dashboard: PersistedDashboard,
536
+ cardLibrary: readonly CardLibraryTemplate[],
537
+ ) {
538
+ const installed = new Map(
539
+ dashboard.cards.map((card) => [card.libraryItemKey, card.id]),
540
+ );
541
+
542
+ return CardLibraryResponseSchema.parse({
543
+ items: cardLibrary.map(
544
+ (template): CardLibraryItem => ({
545
+ key: template.key,
546
+ name: template.name,
547
+ description: template.description,
548
+ visualization: template.visualization,
549
+ defaultLayout: template.defaultLayout,
550
+ addedCardId: installed.get(template.key),
551
+ }),
552
+ ),
553
+ });
554
+ }
555
+
556
+ function firstAvailableLayout(
557
+ cards: readonly PersistedDashboardCard[],
558
+ size: { width: number; height: number },
559
+ ): DashboardCardLayout {
560
+ if (size.width > DASHBOARD_GRID_COLUMNS) {
561
+ throw new DashboardInvalidLayoutError([
562
+ `Card width ${size.width} exceeds the ${DASHBOARD_GRID_COLUMNS}-column grid`,
563
+ ]);
564
+ }
565
+ for (let y = 0; ; y += 1) {
566
+ for (let x = 0; x + size.width <= DASHBOARD_GRID_COLUMNS; x += 1) {
567
+ const candidate = { x, y, ...size };
568
+ if (!cards.some((card) => layoutsOverlap(candidate, card.layout))) {
569
+ return candidate;
570
+ }
571
+ }
572
+ }
573
+ }
574
+
575
+ function layoutsOverlap(a: DashboardCardLayout, b: DashboardCardLayout) {
576
+ return (
577
+ a.x < b.x + b.width &&
578
+ a.x + a.width > b.x &&
579
+ a.y < b.y + b.height &&
580
+ a.y + a.height > b.y
581
+ );
582
+ }
583
+
584
+ async function resolveCardLibrary(
585
+ cardLibrary: DashboardHandlerOptions["cardLibrary"],
586
+ context: DashboardContext,
587
+ ) {
588
+ return typeof cardLibrary === "function"
589
+ ? cardLibrary(context)
590
+ : cardLibrary;
591
+ }
592
+
593
+ function mutationError(error: unknown, invalidMessage: string) {
594
+ if (error instanceof DashboardRevisionConflictError) {
595
+ return errorResponse(
596
+ 409,
597
+ "REVISION_CONFLICT",
598
+ "Dashboard was changed by another request",
599
+ );
600
+ }
601
+ if (error instanceof DashboardNotFoundError) {
602
+ return errorResponse(404, "DASHBOARD_NOT_FOUND", "Dashboard not found");
603
+ }
604
+ if (error instanceof DashboardInvalidLayoutError) {
605
+ return errorResponse(400, "INVALID_REQUEST", invalidMessage);
606
+ }
607
+ return errorResponse(
608
+ 500,
609
+ "DASHBOARD_LOAD_FAILED",
610
+ "Dashboard could not be updated",
611
+ );
612
+ }
613
+
614
+ function libraryError(error: unknown) {
615
+ if (error instanceof DashboardRevisionConflictError) {
616
+ return errorResponse(
617
+ 409,
618
+ "REVISION_CONFLICT",
619
+ "Dashboard was changed by another request",
620
+ );
621
+ }
622
+ if (error instanceof DashboardCardAlreadyAddedError) {
623
+ return errorResponse(
624
+ 409,
625
+ "CARD_ALREADY_ADDED",
626
+ "Card is already on this Dashboard",
627
+ );
628
+ }
629
+ if (error instanceof DashboardInvalidLibraryItemError) {
630
+ return errorResponse(400, "INVALID_REQUEST", "Unknown Card library item");
631
+ }
632
+ if (error instanceof DashboardNotFoundError) {
633
+ return errorResponse(404, "DASHBOARD_NOT_FOUND", "Dashboard not found");
634
+ }
635
+ return errorResponse(
636
+ 500,
637
+ "DASHBOARD_LOAD_FAILED",
638
+ "Dashboard could not be updated",
639
+ );
640
+ }
641
+
642
+ function errorResponse(
643
+ status: number,
644
+ code: DashboardApiError["error"]["code"],
645
+ message: string,
646
+ ) {
647
+ return Response.json(
648
+ DashboardApiErrorSchema.parse({ error: { code, message } }),
649
+ { status },
650
+ );
651
+ }
652
+
653
+ async function readJson(request: Request): Promise<unknown> {
654
+ try {
655
+ return await request.json();
656
+ } catch {
657
+ return undefined;
658
+ }
659
+ }
660
+
661
+ function parseRevision(revision: string) {
662
+ if (!/^[1-9]\d*$/.test(revision)) {
663
+ return undefined;
664
+ }
665
+ const value = Number(revision);
666
+ return Number.isSafeInteger(value) ? value : undefined;
667
+ }
668
+
669
+ function isDashboard(identity: DashboardIdentity) {
670
+ return (
671
+ isIdentitySegment(identity.userId) &&
672
+ isIdentitySegment(identity.dashboardId)
673
+ );
674
+ }
675
+
676
+ function isCard(identity: CardIdentity) {
677
+ return isDashboard(identity) && isIdentitySegment(identity.cardId);
678
+ }
679
+
680
+ function isIdentitySegment(value: string) {
681
+ return value.trim().length > 0 && value.length <= 256;
682
+ }
683
+
684
+ function cardQueryFailed() {
685
+ return errorResponse(
686
+ 502,
687
+ "CARD_QUERY_FAILED",
688
+ "Card data could not be loaded",
689
+ );
690
+ }
691
+
692
+ export {
693
+ DashboardCardAlreadyAddedError,
694
+ DashboardInvalidLayoutError,
695
+ DashboardInvalidLibraryItemError,
696
+ DashboardNotFoundError,
697
+ DashboardRevisionConflictError,
698
+ createDashboardHandlers,
699
+ };
700
+ export type {
701
+ CardDataResolverInput,
702
+ CardLibraryTemplate,
703
+ DashboardBootstrap,
704
+ DashboardHandlerOptions,
705
+ DashboardRepository,
706
+ DashboardSeed,
707
+ DashboardSeedCard,
708
+ PersistedDashboard,
709
+ PersistedDashboardCard,
710
+ };