feat: add dashboards — tables, CRUD API, widget data endpoint

- New dashboards table (name, description, layout, is_default)
- New dashboard_widgets table (view_id, title, widget_type, position, config)
- GET/POST/PATCH/DELETE /dashboards
- GET/POST/PATCH/DELETE /dashboards/:id/widgets
- GET /dashboards/:id/widgets/:id/data — runs saved view filters,
  returns pre-aggregated data for count/ticket_list/status_chart/grouped_counts
- is_default uniqueness enforced on PATCH

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Gjermund Høsøien Wiggen
2026-06-09 11:26:22 +02:00
parent aa90b88991
commit b70a133ea2
15 changed files with 2349 additions and 90 deletions

View File

@@ -123,3 +123,23 @@ export const views = pgTable('views', {
creator_id: uuid('creator_id').references(() => users.id),
created_at: timestamp('created_at', { withTimezone: true }).defaultNow(),
});
export const dashboards = pgTable('dashboards', {
id: uuid('id').primaryKey().defaultRandom(),
name: text('name').notNull(),
description: text('description'),
layout: jsonb('layout').default('[]'),
is_default: boolean('is_default').default(false),
created_at: timestamp('created_at', { withTimezone: true }).defaultNow(),
});
export const dashboardWidgets = pgTable('dashboard_widgets', {
id: uuid('id').primaryKey().defaultRandom(),
dashboard_id: uuid('dashboard_id').notNull().references(() => dashboards.id, { onDelete: 'cascade' }),
view_id: uuid('view_id').notNull().references(() => views.id, { onDelete: 'cascade' }),
title: text('title').notNull(),
widget_type: text('widget_type').notNull(),
position: jsonb('position').default('{"x":0,"y":0,"w":4,"h":2}'),
config: jsonb('config').default('{}'),
created_at: timestamp('created_at', { withTimezone: true }).defaultNow(),
});

View File

@@ -13,6 +13,7 @@ import { createLifecyclesRouter } from './routes/lifecycles.ts';
import { createUsersRouter } from './routes/users.ts';
import { createTemplatesRouter } from './routes/templates.ts';
import { createViewsRouter } from './routes/views.ts';
import { createDashboardsRouter } from './routes/dashboards.ts';
let db: Db | null = null;
@@ -37,6 +38,7 @@ app.route('/lifecycles', createLifecyclesRouter(getDb()));
app.route('/users', createUsersRouter(getDb()));
app.route('/templates', createTemplatesRouter(getDb()));
app.route('/views', createViewsRouter(getDb()));
app.route('/dashboards', createDashboardsRouter(getDb()));
export default app;
export { app };

385
src/routes/dashboards.ts Normal file
View File

@@ -0,0 +1,385 @@
import { Hono } from 'hono';
import { HTTPException } from 'hono/http-exception';
import { asc, eq } from 'drizzle-orm';
import type { Db } from '../db/index.ts';
import {
dashboards,
dashboardWidgets,
tickets,
customFieldValues,
customFields,
lifecycles,
queues,
views,
} from '../db/schema.ts';
function statusClass(def: { statuses: { initial: string[]; active: string[]; inactive: string[] } }, status: string): string {
if (def.statuses.initial.includes(status)) return 'initial';
if (def.statuses.active.includes(status)) return 'active';
if (def.statuses.inactive.includes(status)) return 'inactive';
return 'unknown';
}
export function createDashboardsRouter(db: Db): Hono {
const router = new Hono();
// ── Dashboards CRUD ──
router.get('/', async (c) => {
const result = await db.query.dashboards.findMany({
orderBy: asc(dashboards.name),
});
return c.json(result);
});
router.post('/', async (c) => {
const body = await c.req.json();
const name = String(body.name ?? '').trim();
if (!name) {
throw new HTTPException(400, { message: 'name is required' });
}
const [dashboard] = await db.insert(dashboards).values({
name,
description: body.description ?? null,
layout: body.layout ?? [],
is_default: body.is_default ?? false,
}).returning();
if (!dashboard) {
throw new HTTPException(500, { message: 'Failed to create dashboard' });
}
return c.json(dashboard, 201);
});
router.get('/:id', async (c) => {
const id = c.req.param('id');
const dashboard = await db.query.dashboards.findFirst({
where: eq(dashboards.id, id),
});
if (!dashboard) {
throw new HTTPException(404, { message: 'Dashboard not found' });
}
const widgets = await db.query.dashboardWidgets.findMany({
where: eq(dashboardWidgets.dashboard_id, id),
orderBy: asc(dashboardWidgets.created_at),
});
return c.json({ ...dashboard, widgets });
});
router.patch('/:id', async (c) => {
const id = c.req.param('id');
const body = await c.req.json();
const existing = await db.query.dashboards.findFirst({
where: eq(dashboards.id, id),
});
if (!existing) {
throw new HTTPException(404, { message: 'Dashboard not found' });
}
const updateData: Partial<typeof dashboards.$inferInsert> = {};
if (body.name !== undefined) updateData.name = String(body.name).trim();
if (body.description !== undefined) updateData.description = body.description ?? null;
if (body.layout !== undefined) updateData.layout = body.layout;
if (body.is_default !== undefined) {
updateData.is_default = body.is_default;
// If setting this as default, unset others
if (body.is_default) {
await db.update(dashboards)
.set({ is_default: false })
.where(eq(dashboards.is_default, true));
}
}
const [updated] = await db.update(dashboards)
.set(updateData)
.where(eq(dashboards.id, id))
.returning();
return c.json(updated);
});
router.delete('/:id', async (c) => {
const id = c.req.param('id');
const existing = await db.query.dashboards.findFirst({
where: eq(dashboards.id, id),
});
if (!existing) {
throw new HTTPException(404, { message: 'Dashboard not found' });
}
await db.delete(dashboards).where(eq(dashboards.id, id));
return c.json({ ok: true });
});
// ── Widgets CRUD ──
router.get('/:id/widgets', async (c) => {
const dashboardId = c.req.param('id');
const result = await db.query.dashboardWidgets.findMany({
where: eq(dashboardWidgets.dashboard_id, dashboardId),
orderBy: asc(dashboardWidgets.created_at),
});
return c.json(result);
});
router.post('/:id/widgets', async (c) => {
const dashboardId = c.req.param('id');
const dashboard = await db.query.dashboards.findFirst({
where: eq(dashboards.id, dashboardId),
});
if (!dashboard) {
throw new HTTPException(404, { message: 'Dashboard not found' });
}
const body = await c.req.json();
const title = String(body.title ?? 'Widget').trim();
const widgetType = String(body.widget_type ?? 'count').trim();
const viewId = String(body.view_id ?? '').trim();
if (!viewId) {
throw new HTTPException(400, { message: 'view_id is required' });
}
const [widget] = await db.insert(dashboardWidgets).values({
dashboard_id: dashboardId,
view_id: viewId,
title,
widget_type: widgetType,
position: body.position ?? { x: 0, y: 0, w: 4, h: 2 },
config: body.config ?? {},
}).returning();
if (!widget) {
throw new HTTPException(500, { message: 'Failed to create widget' });
}
return c.json(widget, 201);
});
router.patch('/:id/widgets/:widgetId', async (c) => {
const widgetId = c.req.param('widgetId');
const body = await c.req.json();
const existing = await db.query.dashboardWidgets.findFirst({
where: eq(dashboardWidgets.id, widgetId),
});
if (!existing) {
throw new HTTPException(404, { message: 'Widget not found' });
}
const updateData: Partial<typeof dashboardWidgets.$inferInsert> = {};
if (body.title !== undefined) updateData.title = String(body.title).trim();
if (body.widget_type !== undefined) updateData.widget_type = String(body.widget_type);
if (body.position !== undefined) updateData.position = body.position;
if (body.config !== undefined) updateData.config = body.config;
const [updated] = await db.update(dashboardWidgets)
.set(updateData)
.where(eq(dashboardWidgets.id, widgetId))
.returning();
return c.json(updated);
});
router.delete('/:id/widgets/:widgetId', async (c) => {
const widgetId = c.req.param('widgetId');
const existing = await db.query.dashboardWidgets.findFirst({
where: eq(dashboardWidgets.id, widgetId),
});
if (!existing) {
throw new HTTPException(404, { message: 'Widget not found' });
}
await db.delete(dashboardWidgets).where(eq(dashboardWidgets.id, widgetId));
return c.json({ ok: true });
});
// ── Widget data endpoint ──
router.get('/:id/widgets/:widgetId/data', async (c) => {
const widgetId = c.req.param('widgetId');
const widget = await db.query.dashboardWidgets.findFirst({
where: eq(dashboardWidgets.id, widgetId),
});
if (!widget) {
throw new HTTPException(404, { message: 'Widget not found' });
}
const view = await db.query.views.findFirst({
where: eq(views.id, widget.view_id),
});
if (!view) {
return c.json({ error: 'View not found' }, 404);
}
// Apply saved view filters
const savedFilters = (view.filters ?? []) as { field: string; operator: string; value: string }[];
let result = await db.query.tickets.findMany({
orderBy: asc(tickets.created_at),
});
for (const f of savedFilters) {
if (f.field === 'status') {
result = result.filter((t) => t.status === f.value);
} else if (f.field === 'queue') {
result = result.filter((t) => t.queue_id === f.value);
} else if (f.field === 'owner') {
result = f.value === 'unassigned'
? result.filter((t) => !t.owner_id)
: result.filter((t) => t.owner_id === f.value);
} else if (f.field.startsWith('cf.')) {
const cfKey = f.field.slice(3);
const ticketIds = result.map((t) => t.id);
if (ticketIds.length > 0) {
const cfValues = await db.query.customFieldValues.findMany({
where: (table, { and, inArray, eq }) =>
and(
inArray(table.ticket_id, ticketIds),
eq(table.value, f.value),
),
});
const matchingIds = new Set(cfValues.map((v) => v.ticket_id));
// Also find the field ID for the key
const cfField = await db.query.customFields.findFirst({
where: eq(customFields.key, cfKey),
});
if (cfField) {
const cfValuesForField = await db.query.customFieldValues.findMany({
where: (table, { and, inArray, eq }) =>
and(
inArray(table.ticket_id, ticketIds),
eq(table.custom_field_id, cfField.id),
eq(table.value, f.value),
),
});
const matchSet = new Set(cfValuesForField.map((v) => v.ticket_id));
result = result.filter((t) => matchSet.has(t.id));
} else {
result = result.filter((t) => matchingIds.has(t.id));
}
}
}
}
const limit = (widget.config as Record<string, unknown>)?.limit as number ?? 5;
// Find lifecycle for status classification
const queueIds = [...new Set(result.map((r) => r.queue_id))];
const queueRecords = queueIds.length > 0
? await db.query.queues.findMany({
where: (table, { inArray }) => inArray(table.id, queueIds),
})
: [];
const lifecycleIds = [...new Set(queueRecords.map((q) => q.lifecycle_id).filter(Boolean))] as string[];
const lifecycleRecords = lifecycleIds.length > 0
? await db.query.lifecycles.findMany({
where: (table, { inArray }) => inArray(table.id, lifecycleIds),
})
: [];
const lifecycleByQueue = new Map<string, { statuses: { initial: string[]; active: string[]; inactive: string[] } }>();
for (const qr of queueRecords) {
if (qr.lifecycle_id) {
const lc = lifecycleRecords.find((l) => l.id === qr.lifecycle_id);
if (lc) lifecycleByQueue.set(qr.id, lc.definition as any);
}
}
// Get owner usernames
const ownerIds = [...new Set(result.map((t) => t.owner_id).filter(Boolean))] as string[];
const ownerUsers = ownerIds.length > 0
? await db.query.users.findMany({
where: (table, { inArray }) => inArray(table.id, ownerIds),
})
: [];
const ownerName = new Map(ownerUsers.map((u) => [u.id, u.username]));
// Get queue names
const queueName = new Map(queueRecords.map((q) => [q.id, q.name]));
switch (widget.widget_type) {
case 'count': {
return c.json({ type: 'count', total: result.length, title: widget.title, view_id: view.id });
}
case 'ticket_list': {
const slice = result.slice(0, limit).map((ticket) => ({
id: ticket.id,
subject: ticket.subject,
status: ticket.status,
owner_id: ticket.owner_id,
owner_name: ticket.owner_id ? ownerName.get(ticket.owner_id) ?? null : null,
queue_name: queueName.get(ticket.queue_id) ?? ticket.queue_id.slice(0, 8),
updated_at: ticket.updated_at?.toISOString(),
}));
return c.json({ type: 'ticket_list', tickets: slice, total: result.length, title: widget.title, view_id: view.id });
}
case 'status_chart': {
const counts: Record<string, number> = {};
for (const ticket of result) {
counts[ticket.status] = (counts[ticket.status] ?? 0) + 1;
}
return c.json({ type: 'status_chart', counts, total: result.length, title: widget.title, view_id: view.id });
}
case 'grouped_counts': {
const groupBy = (widget.config as Record<string, unknown>)?.group_by as string ?? 'owner';
const groups: Record<string, number> = {};
if (groupBy === 'owner') {
for (const ticket of result) {
const label = ticket.owner_id
? (ownerName.get(ticket.owner_id) ?? ticket.owner_id.slice(0, 8))
: 'Unassigned';
groups[label] = (groups[label] ?? 0) + 1;
}
} else if (groupBy === 'queue') {
for (const ticket of result) {
const label = queueName.get(ticket.queue_id) ?? ticket.queue_id.slice(0, 8);
groups[label] = (groups[label] ?? 0) + 1;
}
} else if (groupBy.startsWith('cf.')) {
const cfKey = groupBy.slice(3);
const cfField = await db.query.customFields.findFirst({
where: eq(customFields.key, cfKey),
});
if (cfField) {
const ticketIds = result.map((t) => t.id);
const cfValues = ticketIds.length > 0
? await db.query.customFieldValues.findMany({
where: (table, { and, inArray, eq }) =>
and(
inArray(table.ticket_id, ticketIds),
eq(table.custom_field_id, cfField.id),
),
})
: [];
for (const v of cfValues) {
groups[v.value] = (groups[v.value] ?? 0) + 1;
}
}
}
return c.json({ type: 'grouped_counts', groups, total: result.length, group_by: groupBy, title: widget.title, view_id: view.id });
}
default:
return c.json({ type: 'count', total: result.length, title: widget.title, view_id: view.id });
}
});
return router;
}