- Add session-based authentication (login page, middleware, auth context) - Add cron-like scrip scheduler for time-based conditions - Add layout builder, scrip wizard, searchable select components - Add trend chart widget for dashboards - Add notifications, attachments, queue-permissions API routes - Add seed-users script - Update schema with 10 new migrations (0008-0017) - Apply redesign: Linear-inspired dark theme, conversation-centric UI - Gitignore runtime data directory Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
466 lines
16 KiB
TypeScript
466 lines
16 KiB
TypeScript
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,
|
|
team_id: body.team_id || 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.team_id !== undefined) updateData.team_id = body.team_id || null;
|
|
if (body.is_default !== undefined) {
|
|
updateData.is_default = body.is_default;
|
|
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));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Widget-level filters override or add to view filters
|
|
const widgetFilters = (widget.config as Record<string, unknown>)?.filters as Array<{ field: string; operator: string; value: string }> | undefined;
|
|
if (widgetFilters) {
|
|
for (const f of widgetFilters) {
|
|
if (f.field === 'status') {
|
|
if (f.operator === 'is_not') result = result.filter((t) => t.status !== f.value);
|
|
else result = result.filter((t) => t.status === f.value);
|
|
} else if (f.field === 'queue') {
|
|
if (f.operator === 'is_not') result = result.filter((t) => t.queue_id !== f.value);
|
|
else result = result.filter((t) => t.queue_id === f.value);
|
|
} else if (f.field === 'owner') {
|
|
if (f.value === 'unassigned') result = result.filter((t) => !t.owner_id);
|
|
else result = result.filter((t) => t.owner_id === f.value);
|
|
} else if (f.field === 'q') {
|
|
const q = f.value.toLowerCase();
|
|
result = result.filter((t) =>
|
|
t.subject.toLowerCase().includes(q) ||
|
|
String(t.id).includes(q) ||
|
|
(queueName.get(t.queue_id) ?? '').toLowerCase().includes(q)
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
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 'my_tickets': {
|
|
const authUser = c.get('user');
|
|
const myTickets = result.filter((t) => t.owner_id === authUser.userId);
|
|
return c.json({ type: 'my_tickets', total: myTickets.length, title: widget.title, view_id: view.id });
|
|
}
|
|
|
|
case 'trend_chart': {
|
|
const period = ((widget.config as Record<string, unknown>)?.period as string) ?? 'day';
|
|
const days = (widget.config as Record<string, unknown>)?.days as number ?? 30;
|
|
const trendField = ((widget.config as Record<string, unknown>)?.field as string) ?? 'created_at';
|
|
const now = new Date();
|
|
const start = new Date(now.getTime() - days * 24 * 60 * 60 * 1000);
|
|
|
|
const filtered = result.filter((t) => {
|
|
const d = trendField === 'updated_at' ? t.updated_at : t.created_at;
|
|
return d && new Date(d) >= start;
|
|
});
|
|
|
|
const points: Record<string, number> = {};
|
|
for (const t of filtered) {
|
|
const d = new Date(trendField === 'updated_at' ? t.updated_at! : t.created_at!);
|
|
let key: string;
|
|
if (period === 'week') {
|
|
const weekStart = new Date(d);
|
|
weekStart.setDate(d.getDate() - d.getDay());
|
|
key = weekStart.toISOString().slice(0, 10);
|
|
} else {
|
|
key = d.toISOString().slice(0, 10); // YYYY-MM-DD
|
|
}
|
|
points[key] = (points[key] ?? 0) + 1;
|
|
}
|
|
|
|
return c.json({ type: 'trend_chart', counts: points, total: result.length, title: widget.title, view_id: view.id });
|
|
}
|
|
|
|
case 'overdue': {
|
|
const dateFieldKey = (widget.config as Record<string, unknown>)?.field_key as string;
|
|
const now = new Date();
|
|
const overdue = result.filter((t) => {
|
|
if (!dateFieldKey) {
|
|
// No specific field — check if any inactive-adjacent status
|
|
const lc = lifecycleByQueue.get(t.queue_id);
|
|
if (lc) {
|
|
const inactive = lc.statuses.inactive;
|
|
if (inactive.includes(t.status)) return false; // already resolved
|
|
}
|
|
// Check if updated_at is older than 7 days
|
|
const updated = t.updated_at ? new Date(t.updated_at) : new Date(0);
|
|
return (now.getTime() - updated.getTime()) > 7 * 24 * 60 * 60 * 1000;
|
|
}
|
|
return false; // Would need CF value lookup for date field
|
|
});
|
|
return c.json({ type: 'overdue', total: overdue.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;
|
|
}
|